diff --git a/packages/frontend/component/src/ui/drag-handle/index.tsx b/packages/frontend/component/src/ui/drag-handle/index.tsx index 41b65cfc29..eb4e0f3291 100644 --- a/packages/frontend/component/src/ui/drag-handle/index.tsx +++ b/packages/frontend/component/src/ui/drag-handle/index.tsx @@ -8,8 +8,10 @@ export const DragHandle = forwardRef< { className?: string; dragging?: boolean; + width?: number; + height?: number; } ->(({ className, dragging, ...props }, ref) => { +>(({ className, dragging, width = 10, height = 22, ...props }, ref) => { return (
{ items: MasonryItem[] | MasonryGroup[]; gapX?: number; gapY?: number; - paddingX?: number; + paddingX?: MasonryPX; paddingY?: number; groupsGap?: number; @@ -48,6 +59,8 @@ export interface MasonryProps extends React.HTMLAttributes { * Specify the number of columns, will override the calculated */ columns?: number; + resizeDebounce?: number; + preloadHeight?: number; } export const Masonry = ({ @@ -66,6 +79,8 @@ export const Masonry = ({ stickyGroupHeader = true, collapsedGroups, columns, + preloadHeight = 50, + resizeDebounce = 20, onGroupCollapse, ...props }: MasonryProps) => { @@ -74,13 +89,16 @@ export const Masonry = ({ const [layoutMap, setLayoutMap] = useState< Map >(new Map()); - const [sleepMap, setSleepMap] = useState | null>(null); + /** + * Record active items, to ensure all items won't be rendered when initialized. + */ + const [activeMap, setActiveMap] = useState>( + new Map() + ); const [stickyGroupId, setStickyGroupId] = useState( undefined ); + const [totalWidth, setTotalWidth] = useState(0); const stickyGroupCollapsed = !!( collapsedGroups && stickyGroupId && @@ -91,7 +109,7 @@ export const Masonry = ({ if (items.length === 0) { return []; } - if ('items' in items[0]) return items as MasonryGroup[]; + if (items[0] && 'items' in items[0]) return items as MasonryGroup[]; return [{ id: '', height: 0, items: items as MasonryItem[] }]; }, [items]); @@ -100,7 +118,7 @@ export const Masonry = ({ return groups.find(group => group.id === stickyGroupId); }, [groups, stickyGroupId]); - const updateSleepMap = useCallback( + const updateActiveMap = useCallback( (layoutMap: Map, _scrollY?: number) => { if (!virtualScroll) return; @@ -109,16 +127,16 @@ export const Masonry = ({ requestAnimationFrame(() => { const scrollY = _scrollY ?? rootEl.scrollTop; - const sleepMap = calcSleep({ + const activeMap = calcActive({ viewportHeight: rootEl.clientHeight, scrollY, layoutMap, - preloadHeight: 50, + preloadHeight, }); - setSleepMap(sleepMap); + setActiveMap(activeMap); }); }, - [virtualScroll] + [preloadHeight, virtualScroll] ); const calculateLayout = useCallback(() => { @@ -149,7 +167,8 @@ export const Masonry = ({ }); setLayoutMap(layout); setHeight(height); - updateSleepMap(layout); + setTotalWidth(totalWidth); + updateActiveMap(layout); if (stickyGroupHeader && rootRef.current) { setStickyGroupId( calcSticky({ scrollY: rootRef.current.scrollTop, layoutMap: layout }) @@ -168,17 +187,20 @@ export const Masonry = ({ paddingX, paddingY, stickyGroupHeader, - updateSleepMap, + updateActiveMap, ]); // handle resize useEffect(() => { calculateLayout(); if (rootRef.current) { - return observeResize(rootRef.current, debounce(calculateLayout, 50)); + return observeResize( + rootRef.current, + debounce(calculateLayout, resizeDebounce) + ); } return; - }, [calculateLayout]); + }, [calculateLayout, resizeDebounce]); // handle scroll useEffect(() => { @@ -188,7 +210,7 @@ export const Masonry = ({ if (virtualScroll) { const handler = throttle((e: Event) => { const scrollY = (e.target as HTMLElement).scrollTop; - updateSleepMap(layoutMap, scrollY); + updateActiveMap(layoutMap, scrollY); if (stickyGroupHeader) { setStickyGroupId(calcSticky({ scrollY, layoutMap })); } @@ -199,7 +221,7 @@ export const Masonry = ({ }; } return; - }, [layoutMap, stickyGroupHeader, updateSleepMap, virtualScroll]); + }, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]); return ( @@ -211,11 +233,9 @@ export const Masonry = ({ > {groups.map(group => { // sleep is not calculated, do not render - if (virtualScroll && !sleepMap) return null; const { id: groupId, items, - children, className, Component, ...groupProps @@ -223,14 +243,11 @@ export const Masonry = ({ const collapsed = collapsedGroups && collapsedGroups.includes(groupId); - const sleep = - (virtualScroll && sleepMap && sleepMap.get(groupId)) ?? false; - return ( {/* group header */} - {sleep ? null : ( - onGroupCollapse?.(groupId, !collapsed)} - > - {Component ? ( - - ) : ( - children - )} - + Component={Component} + itemCount={items.length} + collapsed={!!collapsed} + groupId={groupId} + paddingX={calcPX(paddingX, totalWidth)} + /> )} {/* group items */} {collapsed ? null : items.map(({ id: itemId, Component, ...item }) => { const mixId = groupId ? `${groupId}:${itemId}` : itemId; - const sleep = - (virtualScroll && sleepMap && sleepMap.get(mixId)) ?? - false; - if (sleep) return null; + if (virtualScroll && !activeMap.get(mixId)) return null; return ( - - {Component ? ( - - ) : ( - item.children - )} - + groupId={groupId} + itemId={itemId} + Component={Component} + /> ); })} @@ -283,10 +289,11 @@ export const Masonry = ({ {stickyGroup ? (
onGroupCollapse?.(stickyGroup.id, !stickyGroupCollapsed) @@ -314,11 +321,87 @@ interface MasonryItemProps xywh?: MasonryItemXYWH; } +const MasonryGroupHeader = memo(function MasonryGroupHeader({ + id, + children, + style, + className, + Component, + groupId, + itemCount, + collapsed, + height, + paddingX, + ...props +}: Omit & { + Component?: MasonryGroup['Component']; + groupId: string; + itemCount: number; + collapsed: boolean; + paddingX?: number; +}) { + const content = useMemo(() => { + if (Component) { + return ( + + ); + } + return children; + }, [Component, children, collapsed, groupId, itemCount]); + + return ( + + {content} + + ); +}); + +const MasonryGroupItem = memo(function MasonryGroupItem({ + id, + children, + className, + Component, + groupId, + itemId, + ...props +}: MasonryItemProps & { + groupId: string; + itemId: string; +}) { + const content = useMemo(() => { + if (Component) { + return ; + } + return children; + }, [Component, children, groupId, itemId]); + + return ( + + {content} + + ); +}); + const MasonryItem = memo(function MasonryItem({ id, xywh, locateMode = 'leftTop', children, + className, style: styleProp, ...props }: MasonryItemProps) { @@ -341,14 +424,19 @@ const MasonryItem = memo(function MasonryItem({ ...posStyle, width: `${w}px`, height: `${h}px`, - position: 'absolute' as const, }; }, [locateMode, styleProp, xywh]); if (!xywh) return null; return ( -
+
{children}
); diff --git a/packages/frontend/component/src/ui/masonry/styles.css.ts b/packages/frontend/component/src/ui/masonry/styles.css.ts index d73ea30db3..c67dcd1772 100644 --- a/packages/frontend/component/src/ui/masonry/styles.css.ts +++ b/packages/frontend/component/src/ui/masonry/styles.css.ts @@ -20,3 +20,7 @@ export const stickyGroupHeader = style({ top: 0, width: '100%', }); + +export const item = style({ + position: 'absolute', +}); diff --git a/packages/frontend/component/src/ui/masonry/type.ts b/packages/frontend/component/src/ui/masonry/type.ts index 29f77ca227..c10d281be8 100644 --- a/packages/frontend/component/src/ui/masonry/type.ts +++ b/packages/frontend/component/src/ui/masonry/type.ts @@ -22,3 +22,5 @@ export interface MasonryItemXYWH { w: number; h: number; } + +export type MasonryPX = number | ((totalWidth: number) => number); diff --git a/packages/frontend/component/src/ui/masonry/utils.ts b/packages/frontend/component/src/ui/masonry/utils.ts index 1bff66ea39..13da3b036d 100644 --- a/packages/frontend/component/src/ui/masonry/utils.ts +++ b/packages/frontend/component/src/ui/masonry/utils.ts @@ -1,13 +1,22 @@ -import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type'; +import type { + MasonryGroup, + MasonryItem, + MasonryItemXYWH, + MasonryPX, +} from './type'; + +export const calcPX = (px: MasonryPX, totalWidth: number) => + typeof px === 'number' ? px : px(totalWidth); export const calcColumns = ( totalWidth: number, itemWidth: number | 'stretch', itemWidthMin: number, gapX: number, - paddingX: number, + _paddingX: MasonryPX, columns?: number ) => { + const paddingX = calcPX(_paddingX, totalWidth); const availableWidth = totalWidth - paddingX * 2; if (columns) { @@ -47,7 +56,7 @@ export const calcLayout = ( width: number; gapX: number; gapY: number; - paddingX: number; + paddingX: MasonryPX; paddingY: number; groupsGap: number; groupHeaderGapWithItems: number; @@ -60,12 +69,13 @@ export const calcLayout = ( width, gapX, gapY, - paddingX, + paddingX: _paddingX, paddingY, groupsGap, groupHeaderGapWithItems, collapsedGroups, } = options; + const paddingX = calcPX(_paddingX, totalWidth); const layout = new Map(); let finalHeight = paddingY; @@ -79,9 +89,9 @@ export const calcLayout = ( // calculate group header const groupHeaderLayout: MasonryItemXYWH = { type: 'group', - x: paddingX, + x: 0, y: finalHeight, - w: totalWidth - paddingX * 2, + w: totalWidth, h: group.height, }; layout.set(group.id, groupHeaderLayout); @@ -97,10 +107,11 @@ export const calcLayout = ( const itemId = group.id ? `${group.id}:${item.id}` : item.id; const minHeight = Math.min(...heightStack); const minHeightIndex = heightStack.indexOf(minHeight); + const hasGap = heightStack[minHeightIndex] ? gapY : 0; const x = minHeightIndex * (width + gapX) + paddingX; - const y = minHeight + finalHeight; + const y = finalHeight + minHeight + hasGap; - heightStack[minHeightIndex] += item.height + gapY; + heightStack[minHeightIndex] += item.height + hasGap; layout.set(itemId, { type: 'item', x, @@ -117,7 +128,7 @@ export const calcLayout = ( return { layout, height: finalHeight }; }; -export const calcSleep = (options: { +export const calcActive = (options: { viewportHeight: number; scrollY: number; layoutMap: Map; @@ -125,7 +136,7 @@ export const calcSleep = (options: { }) => { const { viewportHeight, scrollY, layoutMap, preloadHeight } = options; - const sleepMap = new Map(); + const activeMap = new Map(); layoutMap.forEach((layout, id) => { const { y, h } = layout; @@ -134,10 +145,12 @@ export const calcSleep = (options: { y + h + preloadHeight > scrollY && y - preloadHeight < scrollY + viewportHeight; - sleepMap.set(id, !isInView); + if (isInView) { + activeMap.set(id, true); + } }); - return sleepMap; + return activeMap; }; export const calcSticky = (options: { diff --git a/packages/frontend/core/src/components/explorer/context.ts b/packages/frontend/core/src/components/explorer/context.ts new file mode 100644 index 0000000000..f04af7646f --- /dev/null +++ b/packages/frontend/core/src/components/explorer/context.ts @@ -0,0 +1,28 @@ +import { createContext, type Dispatch, type SetStateAction } from 'react'; + +import type { ExplorerPreference } from './types'; + +export type DocExplorerContextType = ExplorerPreference & { + view: 'list' | 'grid' | 'masonry'; + groups: Array<{ key: string; items: string[] }>; + collapsed: string[]; + selectMode?: boolean; + selectedDocIds: string[]; + prevCheckAnchorId?: string | null; + onToggleCollapse: (groupId: string) => void; + onToggleSelect: (docId: string) => void; + onSelect: Dispatch>; + setPrevCheckAnchorId: Dispatch>; +}; + +export const DocExplorerContext = createContext({ + view: 'list', + groups: [], + collapsed: [], + selectedDocIds: [], + prevCheckAnchorId: null, + onToggleSelect: () => {}, + onToggleCollapse: () => {}, + onSelect: () => {}, + setPrevCheckAnchorId: () => {}, +}); diff --git a/packages/frontend/core/src/components/explorer/display-menu/index.tsx b/packages/frontend/core/src/components/explorer/display-menu/index.tsx index 3ca159b0fd..1ac1487218 100644 --- a/packages/frontend/core/src/components/explorer/display-menu/index.tsx +++ b/packages/frontend/core/src/components/explorer/display-menu/index.tsx @@ -1,4 +1,4 @@ -import { Button, Menu, MenuSub } from '@affine/component'; +import { Button, Divider, Menu, MenuSub } from '@affine/component'; import type { GroupByParams, OrderByParams, @@ -11,6 +11,8 @@ import { useCallback } from 'react'; import type { ExplorerPreference } from '../types'; import { GroupByList, GroupByName } from './group'; import { OrderByList, OrderByName } from './order'; +import { DisplayProperties } from './properties'; +import { QuickActionsConfig } from './quick-actions'; import * as styles from './styles.css'; const ExplorerDisplayMenu = ({ @@ -78,6 +80,10 @@ const ExplorerDisplayMenu = ({
+ + + +
); }; diff --git a/packages/frontend/core/src/components/explorer/display-menu/properties.css.ts b/packages/frontend/core/src/components/explorer/display-menu/properties.css.ts new file mode 100644 index 0000000000..b9209233d8 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/properties.css.ts @@ -0,0 +1,31 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + flexDirection: 'column', + gap: 4, +}); + +export const properties = style({ + padding: '4px 0px 8px 0px', + display: 'flex', + gap: 4, + flexWrap: 'wrap', +}); + +export const sectionLabel = style({ + fontSize: 14, + lineHeight: '22px', + color: cssVarV2.text.primary, + padding: 4, +}); + +export const property = style({ + selectors: { + '&[data-show="false"]': { + backgroundColor: cssVarV2.button.emptyIconBackground, + color: cssVarV2.icon.disable, + }, + }, +}); diff --git a/packages/frontend/core/src/components/explorer/display-menu/properties.tsx b/packages/frontend/core/src/components/explorer/display-menu/properties.tsx new file mode 100644 index 0000000000..b73e67a3c4 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/properties.tsx @@ -0,0 +1,144 @@ +import { Button, Divider } from '@affine/component'; +import { + WorkspacePropertyService, + type WorkspacePropertyType, +} from '@affine/core/modules/workspace-property'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +import { WorkspacePropertyName } from '../../properties'; +import { WorkspacePropertyTypes } from '../../workspace-property-types'; +import type { ExplorerPreference } from '../types'; +import * as styles from './properties.css'; + +export const filterDisplayProperties = < + T extends { type: WorkspacePropertyType }, +>( + propertyList: T[], + showInDocList: 'inline' | 'stack' +) => { + return propertyList + .filter( + property => + WorkspacePropertyTypes[property.type].showInDocList === showInDocList + ) + .map(property => ({ + property, + config: WorkspacePropertyTypes[property.type], + })); +}; + +export const DisplayProperties = ({ + preference, + onChange, +}: { + preference: ExplorerPreference; + onChange?: (preference: ExplorerPreference) => void; +}) => { + const t = useI18n(); + const workspacePropertyService = useService(WorkspacePropertyService); + const propertyList = useLiveData(workspacePropertyService.properties$); + + const displayProperties = preference.displayProperties; + const showIcon = preference.showDocIcon; + const showBody = preference.showDocPreview; + + const propertiesGroups = useMemo( + () => [ + { + type: 'inline', + properties: filterDisplayProperties(propertyList, 'inline'), + }, + { + type: 'stack', + properties: filterDisplayProperties(propertyList, 'stack'), + }, + ], + [propertyList] + ); + + const handleDisplayPropertiesChange = useCallback( + (displayProperties: string[]) => { + onChange?.({ + ...preference, + displayProperties, + }); + }, + [onChange, preference] + ); + + const handlePropertyClick = useCallback( + (propertyId: string) => { + handleDisplayPropertiesChange( + displayProperties && displayProperties.includes(propertyId) + ? displayProperties.filter(id => id !== propertyId) + : [...(displayProperties || []), propertyId] + ); + }, + [displayProperties, handleDisplayPropertiesChange] + ); + + const toggleIcon = useCallback(() => { + onChange?.({ + ...preference, + showDocIcon: !showIcon, + }); + }, [onChange, preference, showIcon]); + + const toggleBody = useCallback(() => { + onChange?.({ + ...preference, + showDocPreview: !showBody, + }); + }, [onChange, preference, showBody]); + + return ( +
+
+ {t['com.affine.all-docs.display.properties']()} +
+ {propertiesGroups.map(list => { + return ( +
+ {list.properties.map(({ property }) => { + return ( + + ); + })} +
+ ); + })} + +
+ {t['com.affine.all-docs.display.list-view']()} +
+
+ + +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/explorer/display-menu/quick-actions.tsx b/packages/frontend/core/src/components/explorer/display-menu/quick-actions.tsx new file mode 100644 index 0000000000..428e9e4975 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/quick-actions.tsx @@ -0,0 +1,50 @@ +import { Checkbox, MenuItem, MenuSub } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { useCallback } from 'react'; + +import { type QuickActionKey, quickActions } from '../quick-actions.constants'; +import type { ExplorerPreference } from '../types'; + +export const QuickActionsConfig = ({ + preference, + onChange, +}: { + preference: ExplorerPreference; + onChange?: (preference: ExplorerPreference) => void; +}) => { + const t = useI18n(); + + const toggleAction = useCallback( + (key: QuickActionKey) => { + onChange?.({ + ...preference, + [key]: !preference[key], + }); + }, + [preference, onChange] + ); + + return ( + { + if (action.disabled) return null; + + return ( + { + // do not close sub menu + e.preventDefault(); + toggleAction(action.key); + }} + prefixIcon={} + > + {t.t(action.name)} + + ); + })} + > + {t['com.affine.all-docs.quick-actions']()} + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts new file mode 100644 index 0000000000..dd8fd999be --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts @@ -0,0 +1,223 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const root = style({ + width: '100%', + height: '100%', +}); + +export const listViewRoot = style({ + padding: '0px 4px', + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: 4, + overflow: 'hidden', + containerName: 'list-view-root', + containerType: 'size', + selectors: { + '&:hover': { + backgroundColor: cssVarV2.layer.background.hoverOverlay, + }, + }, +}); + +export const dragHandle = style({ + position: 'absolute', + padding: '5px 2px', + color: cssVarV2.icon.secondary, +}); +export const listDragHandle = style([ + dragHandle, + { + left: -4, + top: '50%', + transform: 'translateY(-50%) translateX(-100%)', + opacity: 0, + selectors: { + [`${listViewRoot}:hover &`]: { + opacity: 1, + }, + }, + }, +]); +export const listSelect = style({ + width: 0, + height: 24, + fontSize: 20, + padding: 2, + // to make sure won't take place when hidden + // 12 = gap + padding * 2 + marginLeft: -12, + flexShrink: 0, + display: 'flex', + color: cssVarV2.icon.primary, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'end', + transition: 'width 0.25s ease, margin-left 0.25s ease', + // when select mode is on, the whole item can be clicked, + // the selection will be handled by the parent, the checkbox here just for the visual effect + pointerEvents: 'none', + selectors: { + '&[data-select-mode="true"]': { + width: 24, + marginLeft: 0, + }, + }, +}); + +export const listIcon = style({ + width: 24, + height: 24, + fontSize: 24, + color: cssVarV2.icon.primary, +}); +export const listContent = style({ + width: 0, + height: '100%', + flex: 1, + display: 'flex', + alignItems: 'center', + gap: 8, + justifyContent: 'space-between', +}); +export const listBrief = style({ + height: '100%', + flexShrink: 10, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + marginLeft: 4, + minWidth: 200, +}); +export const listSpace = style({ + width: 0, + flex: 1, +}); +// export const listDetails = style({ +// display: 'flex', +// gap: 8, +// alignItems: 'center', +// flexShrink: 1, +// minWidth: 0, +// justifyContent: 'flex-end', +// }); +const ellipsis = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); +export const listTitle = style([ + ellipsis, + { + fontSize: 14, + lineHeight: '22px', + fontWeight: 500, + color: cssVarV2.text.primary, + }, +]); +export const listPreview = style([ + ellipsis, + { + fontSize: 12, + lineHeight: '20px', + fontWeight: 400, + color: cssVarV2.text.secondary, + }, +]); + +export const listQuickActions = style({ + display: 'flex', + gap: 8, + flexShrink: 0, +}); + +export const listHide750 = style({ + '@container': { + 'list-view-root (width <= 750px)': { + display: 'none', + }, + }, +}); + +export const listHide560 = style({ + '@container': { + 'list-view-root (width <= 560px)': { + display: 'none', + }, + }, +}); + +// --- card view --- +export const cardViewRoot = style({ + vars: { + '--ring-color': 'transparent', + }, + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 16, + borderRadius: 12, + backgroundColor: cssVarV2.layer.background.mobile.secondary, + border: `0.5px solid ${cssVarV2.layer.insideBorder.border}`, + // TODO: use variable + boxShadow: '0px 0px 0px 1px var(--ring-color),0px 0px 3px rgba(0,0,0,.05)', + overflow: 'hidden', + selectors: { + [`${root}[data-selected="true"] &`]: { + vars: { + '--ring-color': cssVarV2.layer.insideBorder.primaryBorder, + }, + }, + }, +}); +export const cardViewHeader = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, +}); +export const cardViewIcon = style({ + fontSize: 24, + color: cssVarV2.icon.primary, + lineHeight: 0, +}); +export const cardViewTitle = style({ + fontSize: 18, + lineHeight: '26px', + fontWeight: 600, + color: cssVarV2.text.primary, + letterSpacing: '-0.24px', + width: 0, + flexGrow: 1, + flexShrink: 1, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', +}); +export const cardPreviewContainer = style({ + width: '100%', + fontSize: 12, + lineHeight: '20px', + fontWeight: 400, + color: cssVarV2.text.primary, + minHeight: 20, + flexGrow: 1, + flexShrink: 1, + overflow: 'hidden', +}); +export const cardViewCheckbox = style({ + width: 20, + height: 20, + fontSize: 16, + padding: 2, + color: cssVarV2.icon.primary, + pointerEvents: 'none', +}); diff --git a/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx new file mode 100644 index 0000000000..0efdf7956c --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx @@ -0,0 +1,400 @@ +import { + Checkbox, + DragHandle as DragHandleIcon, + Skeleton, + useDraggable, +} from '@affine/component'; +import { DocsService } from '@affine/core/modules/doc'; +import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { WorkbenchLink } from '@affine/core/modules/workbench'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { + AutoTidyUpIcon, + PropertyIcon, + ResizeTidyUpIcon, +} from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { + type HTMLProps, + memo, + type ReactNode, + type SVGProps, + useCallback, + useContext, + useState, +} from 'react'; + +import { PagePreview } from '../../page-list/page-content-preview'; +import { DocExplorerContext } from '../context'; +import { quickActions } from '../quick-actions.constants'; +import * as styles from './doc-list-item.css'; +import { MoreMenuButton } from './more-menu'; +import { CardViewProperties, ListViewProperties } from './properties'; + +export type DocListItemView = 'list' | 'grid' | 'masonry'; + +export const DocListViewIcon = ({ + view, + ...props +}: { view: DocListItemView } & SVGProps) => { + const Component = { + list: PropertyIcon, + grid: ResizeTidyUpIcon, + masonry: AutoTidyUpIcon, + }[view]; + + return ; +}; + +export interface DocListItemProps { + docId: string; + groupId: string; +} + +class MixId { + static connector = '||'; + static create(groupId: string, docId: string) { + return `${groupId}${this.connector}${docId}`; + } + static parse(mixId: string) { + if (!mixId) { + return { groupId: null, docId: null }; + } + const [groupId, docId] = mixId.split(this.connector); + return { groupId, docId }; + } +} +export const DocListItem = ({ ...props }: DocListItemProps) => { + const { + view, + groups, + selectMode, + selectedDocIds, + prevCheckAnchorId, + onSelect, + onToggleSelect, + setPrevCheckAnchorId, + } = useContext(DocExplorerContext); + + const handleMultiSelect = useCallback( + (prevCursor: string, currCursor: string) => { + const flattenList = groups.flatMap(group => + group.items.map(docId => MixId.create(group.key, docId)) + ); + const prevIndex = flattenList.indexOf(prevCursor); + const currIndex = flattenList.indexOf(currCursor); + + onSelect(prev => { + const lowerIndex = Math.min(prevIndex, currIndex); + const upperIndex = Math.max(prevIndex, currIndex); + + const resSet = new Set(prev); + const handledSet = new Set(); + for (let i = lowerIndex; i <= upperIndex; i++) { + const mixId = flattenList[i]; + const { groupId, docId } = MixId.parse(mixId); + if (groupId === null || docId === null) { + continue; + } + if (handledSet.has(docId) || mixId === prevCursor) { + continue; + } + if (resSet.has(docId)) { + resSet.delete(docId); + } else { + resSet.add(docId); + } + handledSet.add(docId); + } + return [...resSet]; + }); + setPrevCheckAnchorId(currCursor); + }, + [groups, onSelect, setPrevCheckAnchorId] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const { docId, groupId } = props; + const currCursor = MixId.create(groupId, docId); + if (selectMode || e.shiftKey) { + e.preventDefault(); + } + + if (selectMode) { + if (e.shiftKey && prevCheckAnchorId) { + // do multi select + handleMultiSelect(prevCheckAnchorId, currCursor); + } else { + onToggleSelect(docId); + setPrevCheckAnchorId(currCursor); + } + } else { + if (e.shiftKey) { + onToggleSelect(docId); + setPrevCheckAnchorId(currCursor); + return; + } else { + // as link + return; + } + } + }, + [ + handleMultiSelect, + onToggleSelect, + prevCheckAnchorId, + props, + selectMode, + setPrevCheckAnchorId, + ] + ); + + return ( + + {view === 'list' ? ( + + ) : ( + + )} + + ); +}; + +const RawDocIcon = memo(function RawDocIcon({ + id, + ...props +}: HTMLProps) { + const docDisplayMetaService = useService(DocDisplayMetaService); + const Icon = useLiveData(id ? docDisplayMetaService.icon$(id) : null); + return ; +}); +const RawDocTitle = memo(function RawDocTitle({ id }: { id: string }) { + const i18n = useI18n(); + const docDisplayMetaService = useService(DocDisplayMetaService); + const title = useLiveData(docDisplayMetaService.title$(id)); + return i18n.t(title); +}); +const RawDocPreview = memo(function RawDocPreview({ + id, + loading, +}: { + id: string; + loading?: ReactNode; +}) { + return ; +}); +const DragHandle = memo(function DragHandle({ + id, + preview, + ...props +}: HTMLProps & { preview?: ReactNode }) { + const { selectMode } = useContext(DocExplorerContext); + + const { dragRef, CustomDragPreview } = useDraggable( + () => ({ + canDrag: true, + data: { + entity: { + type: 'doc', + id: id as string, + }, + from: { + at: 'all-docs:list', + }, + }, + }), + [id] + ); + + if (selectMode || !id) { + return null; + } + + return ( + <> +
+ +
+ + {preview ?? ( + <> + + + + )} + + + ); +}); +const Select = memo(function Select({ + id, + ...props +}: HTMLProps) { + const { selectMode, selectedDocIds, onToggleSelect } = + useContext(DocExplorerContext); + + const handleSelectChange = useCallback(() => { + id && onToggleSelect(id); + }, [id, onToggleSelect]); + + if (!id) { + return null; + } + + return ( +
+ +
+ ); +}); +// Different with RawDocIcon, refer to `ExplorerPreference.showDocIcon` +const DocIcon = memo(function DocIcon({ + id, + ...props +}: HTMLProps) { + const { showDocIcon } = useContext(DocExplorerContext); + if (!showDocIcon) { + return null; + } + return ( +
+ +
+ ); +}); +const DocTitle = memo(function DocTitle({ + id, + ...props +}: HTMLProps) { + if (!id) return null; + return ( +
+ +
+ ); +}); +const DocPreview = memo(function DocPreview({ + id, + loading, + ...props +}: HTMLProps & { loading?: ReactNode }) { + const { showDocPreview } = useContext(DocExplorerContext); + + if (!id || !showDocPreview) return null; + + return ( +
+ +
+ ); +}); + +const listMoreMenuContentOptions = { + side: 'bottom', + align: 'end', + sideOffset: 12, + alignOffset: -4, +} as const; +export const ListViewDoc = ({ docId }: DocListItemProps) => { + const docsService = useService(DocsService); + const doc = useLiveData(docsService.list.doc$(docId)); + const [previewSkeletonWidth] = useState( + Math.round(100 + Math.random() * 100) + ); + + if (!doc) { + return null; + } + + return ( +
  • + + + ) : ( + + )} + + + {previewSkeleton.map(({ id, width }) => ( + + ))} +
  • + } + /> + + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/docs-view/group-header.css.ts b/packages/frontend/core/src/components/explorer/docs-view/group-header.css.ts new file mode 100644 index 0000000000..01b2580f8b --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/group-header.css.ts @@ -0,0 +1,99 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const groupHeader = style({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + position: 'relative', + padding: '0px 4px', + borderRadius: 4, + ':hover': { + background: cssVarV2.layer.background.hoverOverlay, + }, +}); +export const space = style({ + width: 0, + flex: 1, +}); +export const plainTextGroupHeaderIcon = style({ + width: 24, + height: 24, + fontSize: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); +export const plainTextGroupHeader = style({ + gap: 4, + display: 'flex', + alignItems: 'center', + paddingLeft: 4, + selectors: { + [`&:has(${plainTextGroupHeaderIcon})`]: { + paddingLeft: 0, + }, + }, +}); + +const showOnHover = style({ + opacity: 0, + selectors: { + [`${groupHeader}:hover &`]: { + opacity: 1, + }, + }, +}); + +export const collapseButton = style([ + showOnHover, + { + width: 20, + height: 20, + marginLeft: 2, + fontSize: 16, + flexShrink: 0, + color: cssVarV2.icon.primary, + }, +]); +export const collapseButtonIcon = style({ + vars: { + '--rotate': '90deg', + }, + transition: 'transform 0.23s cubic-bezier(.56,.15,.37,.97)', + transform: 'rotate(var(--rotate))', + selectors: { + [`${groupHeader}[data-collapsed="true"] &`]: { + vars: { + '--rotate': '0deg', + }, + }, + }, +}); + +export const selectInfo = style({ + fontSize: 14, + lineHeight: '22px', + color: cssVarV2.text.tertiary, + marginLeft: 12, +}); + +export const content = style({ + flexShrink: 0, + fontSize: 15, + lineHeight: '24px', + color: cssVarV2.text.secondary, +}); + +export const selectAllButton = style([ + showOnHover, + { + padding: '0px 4px', + fontSize: 12, + lineHeight: '20px', + color: cssVarV2.text.secondary, + borderRadius: 4, + }, +]); diff --git a/packages/frontend/core/src/components/explorer/docs-view/group-header.tsx b/packages/frontend/core/src/components/explorer/docs-view/group-header.tsx new file mode 100644 index 0000000000..5a7a7591a3 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/group-header.tsx @@ -0,0 +1,120 @@ +import { Button, IconButton } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { ToggleRightIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { + type HTMLAttributes, + type ReactNode, + useCallback, + useContext, +} from 'react'; + +import { DocExplorerContext } from '../context'; +import * as styles from './group-header.css'; + +export const DocGroupHeader = ({ + className, + groupId, + ...props +}: HTMLAttributes & { + groupId: string; +}) => { + const t = useI18n(); + const { + selectMode, + collapsed, + groups, + selectedDocIds, + onToggleCollapse, + onSelect, + } = useContext(DocExplorerContext); + + const group = groups.find(g => g.key === groupId); + const isGroupAllSelected = group?.items.every(id => + selectedDocIds.includes(id) + ); + + const handleToggleCollapse = useCallback(() => { + onToggleCollapse(groupId); + }, [groupId, onToggleCollapse]); + + const handleSelectAll = useCallback(() => { + if (isGroupAllSelected) { + onSelect(prev => prev.filter(id => !group?.items.includes(id))); + } else { + onSelect(prev => { + const newSelected = [...prev]; + group?.items.forEach(id => { + if (!newSelected.includes(id)) { + newSelected.push(id); + } + }); + return newSelected; + }); + } + }, [group?.items, isGroupAllSelected, onSelect]); + + const selectedCount = group?.items.filter(id => + selectedDocIds.includes(id) + ).length; + + return ( +
    +
    + {selectMode ? ( +
    + {selectedCount}/{group?.items.length} +
    + ) : null} + } + onClick={handleToggleCollapse} + /> +
    + +
    + ); +}; + +export const PlainTextDocGroupHeader = ({ + groupId, + docCount, + className, + children, + icon, + ...props +}: HTMLAttributes & { + groupId: string; + docCount: number; + icon?: ReactNode; +}) => { + return ( + + {icon ? ( +
    {icon}
    + ) : null} +
    {children ?? groupId}
    +
    ยท
    +
    {docCount}
    +
    + ); +}; diff --git a/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx b/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx new file mode 100644 index 0000000000..0a66776ac4 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx @@ -0,0 +1,211 @@ +import { + IconButton, + type IconButtonProps, + Menu, + MenuItem, + type MenuProps, + useConfirmModal, +} from '@affine/component'; +import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; +import { DocsService } from '@affine/core/modules/doc'; +import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { + DeleteIcon, + DuplicateIcon, + InformationIcon, + MoreVerticalIcon, + OpenInNewIcon, + SplitViewIcon, +} from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback } from 'react'; + +import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper'; +import { IsFavoriteIcon } from '../../pure/icons'; + +interface DocOperationProps { + docId: string; +} + +/** + * Favorite Operation + */ +const ToggleFavorite = ({ docId }: DocOperationProps) => { + const t = useI18n(); + const favAdapter = useService(CompatibleFavoriteItemsAdapter); + const favourite = useLiveData(favAdapter.isFavorite$(docId, 'doc')); + + const toggleFavorite = useCallback(() => { + favAdapter.toggle(docId, 'doc'); + }, [docId, favAdapter]); + + return ( + } + onClick={toggleFavorite} + > + {favourite + ? t['com.affine.favoritePageOperation.remove']() + : t['com.affine.favoritePageOperation.add']()} + + ); +}; + +/** + * Doc Info Operation + */ +const DocInfo = ({ docId }: DocOperationProps) => { + const t = useI18n(); + const workspaceDialogService = useService(WorkspaceDialogService); + + const onOpenInfoModal = useCallback(() => { + if (docId) { + track.$.docInfoPanel.$.open(); + workspaceDialogService.open('doc-info', { docId }); + } + }, [docId, workspaceDialogService]); + + return ( + }> + {t['com.affine.page-properties.page-info.view']()} + + ); +}; + +/** + * Open in New Tab Operation + */ +const NewTab = ({ docId }: DocOperationProps) => { + const t = useI18n(); + const workbench = useService(WorkbenchService).workbench; + const onOpenInNewTab = useCallback(() => { + workbench.openDoc(docId, { at: 'new-tab' }); + }, [docId, workbench]); + + return ( + }> + {t['com.affine.workbench.tab.page-menu-open']()} + + ); +}; + +/** + * Open in Split View Operation + */ +const SplitView = ({ docId }: DocOperationProps) => { + const t = useI18n(); + const workbench = useService(WorkbenchService).workbench; + + const onOpenInSplitView = useCallback(() => { + track.allDocs.list.docMenu.openInSplitView(); + workbench.openDoc(docId, { at: 'tail' }); + }, [docId, workbench]); + + return ( + }> + {t['com.affine.workbench.tab.page-menu-open']()} + + ); +}; + +/** + * Duplicate Operation + */ +const Duplicate = ({ docId }: DocOperationProps) => { + const { duplicate } = useBlockSuiteMetaHelper(); + const t = useI18n(); + + const onDuplicate = useCallback(() => { + duplicate(docId, false); + track.allDocs.list.docMenu.createDoc({ + control: 'duplicate', + }); + }, [docId, duplicate]); + + return ( + } onSelect={onDuplicate}> + {t['com.affine.header.option.duplicate']()} + + ); +}; + +/** + * Move to Trash Operation + */ +const MoveToTrash = ({ docId }: DocOperationProps) => { + const t = useI18n(); + const docsService = useService(DocsService); + const { openConfirmModal } = useConfirmModal(); + const doc = useLiveData(docsService.list.doc$(docId)); + + const onMoveToTrash = useCallback(() => { + if (!doc) { + return; + } + + track.allDocs.list.docMenu.deleteDoc(); + openConfirmModal({ + title: t['com.affine.moveToTrash.confirmModal.title'](), + description: t['com.affine.moveToTrash.confirmModal.description']({ + title: doc.title$.value || t['Untitled'](), + }), + cancelText: t['com.affine.confirmModal.button.cancel'](), + confirmText: t.Delete(), + confirmButtonOptions: { + variant: 'error', + }, + onConfirm: () => { + doc.moveToTrash(); + }, + }); + }, [doc, openConfirmModal, t]); + + return ( + } onClick={onMoveToTrash}> + {t['com.affine.moveToTrash.title']()} + + ); +}; + +export const MoreMenuContent = (props: DocOperationProps) => { + return ( + <> + + + + {BUILD_CONFIG.isElectron ? : null} + + + + ); +}; + +export const MoreMenu = ({ + docId, + children, + ...menuProps +}: Omit & { docId: string }) => { + return ( + } {...menuProps}> + {children} + + ); +}; + +export const MoreMenuButton = ({ + docId, + iconProps, + ...menuProps +}: Omit & { + docId: string; + iconProps?: IconButtonProps; +}) => { + return ( + + } {...iconProps} /> + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/docs-view/properties.css.ts b/packages/frontend/core/src/components/explorer/docs-view/properties.css.ts new file mode 100644 index 0000000000..f5dfa13f9b --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/properties.css.ts @@ -0,0 +1,103 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +// export const root = style({ +// display: 'flex', +// gap: 8, +// justifyContent: 'flex-end', +// minWidth: 0, +// flexGrow: 1, +// flexShrink: 0, +// }); +export const stackContainer = style({ + display: 'flex', + gap: 8, + flexShrink: 1.5, + minWidth: 0, + justifyContent: 'flex-end', +}); +export const stackProperties = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: 10, + minWidth: 0, + flexGrow: 1, + flexShrink: 5, + transition: 'flex-shrink 0.23s cubic-bezier(.56,.15,.37,.97)', + selectors: { + '&:empty': { + display: 'none', + }, + '&:not(:empty)': { + minWidth: 40, + }, + '&:hover': { + flexShrink: 0.2, + }, + }, +}); + +export const stackItem = style({ + display: 'flex', + alignItems: 'center', + minWidth: 0, + maxWidth: 128, + + selectors: { + '&:last-child': { + minWidth: 'fit-content', + }, + }, +}); +export const stackItemContent = style({ + height: 24, + borderRadius: 12, + borderWidth: 1, + borderStyle: 'solid', + borderColor: cssVarV2.layer.insideBorder.blackBorder, + padding: '0px 8px 0px 6px', + display: 'flex', + alignItems: 'center', + gap: 4, + maxWidth: 'min(128px, 300%)', + minWidth: 48, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + backgroundColor: cssVarV2.layer.background.primary, + flexShrink: 0, +}); + +export const stackItemIcon = style({ + width: 16, + height: 16, + fontSize: 16, + color: cssVarV2.icon.primary, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const stackItemLabel = style({ + fontSize: 12, + lineHeight: '20px', + color: cssVarV2.text.primary, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', +}); + +export const inlineProperty = style({ + flexShrink: 0, + selectors: { + '&:empty': { + display: 'none', + }, + }, +}); + +export const cardProperties = style({ + display: 'flex', + flexWrap: 'wrap', + gap: 8, +}); diff --git a/packages/frontend/core/src/components/explorer/docs-view/properties.tsx b/packages/frontend/core/src/components/explorer/docs-view/properties.tsx new file mode 100644 index 0000000000..7cbaac9859 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/properties.tsx @@ -0,0 +1,214 @@ +import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; +import { type DocRecord, DocsService } from '@affine/core/modules/doc'; +import { + WorkspacePropertyService, + type WorkspacePropertyType, +} from '@affine/core/modules/workspace-property'; +import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { useContext, useMemo } from 'react'; + +import type { WorkspacePropertyTypes } from '../../workspace-property-types'; +import { DocExplorerContext } from '../context'; +import { filterDisplayProperties } from '../display-menu/properties'; +import { listHide560, listHide750 } from './doc-list-item.css'; +import * as styles from './properties.css'; + +const listInlinePropertyOrder: WorkspacePropertyType[] = [ + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +]; +const cardInlinePropertyOrder: WorkspacePropertyType[] = [ + 'createdBy', + 'updatedBy', + 'createdAt', + 'updatedAt', +]; + +const useProperties = (docId: string, view: 'list' | 'card') => { + const { displayProperties } = useContext(DocExplorerContext); + const docsService = useService(DocsService); + const workspacePropertyService = useService(WorkspacePropertyService); + + const doc = useLiveData(docsService.list.doc$(docId)); + const properties = useLiveData(doc?.properties$); + const propertyList = useLiveData(workspacePropertyService.properties$); + + const stackProperties = useMemo( + () => (properties ? filterDisplayProperties(propertyList, 'stack') : []), + [properties, propertyList] + ); + const inlineProperties = useMemo( + () => + properties + ? filterDisplayProperties(propertyList, 'inline') + .filter(p => p.property.type !== 'tags') + .sort((a, b) => { + const orderList = + view === 'list' + ? listInlinePropertyOrder + : cardInlinePropertyOrder; + const aIndex = orderList.indexOf(a.property.type); + const bIndex = orderList.indexOf(b.property.type); + // Push un-recognised types to the tail instead of the head + return ( + (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - + (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex) + ); + }) + : [], + [properties, propertyList, view] + ); + const tagsProperty = useMemo(() => { + return propertyList + ? filterDisplayProperties(propertyList, 'inline').find( + prop => prop.property.type === 'tags' + ) + : undefined; + }, [propertyList]); + + return useMemo( + () => ({ + doc, + displayProperties, + stackProperties, + inlineProperties, + tagsProperty, + }), + [doc, displayProperties, stackProperties, inlineProperties, tagsProperty] + ); +}; +export const ListViewProperties = ({ docId }: { docId: string }) => { + const { + doc, + displayProperties, + stackProperties, + inlineProperties, + tagsProperty, + } = useProperties(docId, 'list'); + + if (!doc) { + return null; + } + + return ( + <> + {/* stack properties */} +
    +
    + {stackProperties.map(({ property, config }) => { + if (!displayProperties?.includes(property.id)) { + return null; + } + return ( + + ); + })} +
    + {tagsProperty && + displayProperties?.includes(tagsProperty.property.id) ? ( +
    + +
    + ) : null} +
    + {/* inline properties */} + {inlineProperties.map(({ property, config }) => { + if (!displayProperties?.includes(property.id)) { + return null; + } + return ( +
    + +
    + ); + })} + + ); +}; + +export const CardViewProperties = ({ docId }: { docId: string }) => { + const { + doc, + displayProperties, + stackProperties, + inlineProperties, + tagsProperty, + } = useProperties(docId, 'card'); + + if (!doc) { + return null; + } + + return ( +
    + {inlineProperties.map(({ property, config }) => { + if (!displayProperties?.includes(property.id)) { + return null; + } + return ( +
    + +
    + ); + })} + {tagsProperty && displayProperties?.includes(tagsProperty.property.id) ? ( + + ) : null} + {stackProperties.map(({ property, config }) => { + if (!displayProperties?.includes(property.id)) { + return null; + } + return ( + + ); + })} +
    + ); +}; + +const PropertyRenderer = ({ + property, + doc, + config, +}: { + property: DocCustomPropertyInfo; + doc: DocRecord; + config: (typeof WorkspacePropertyTypes)[keyof typeof WorkspacePropertyTypes]; +}) => { + const customPropertyValue = useLiveData(doc.customProperty$(property.id)); + if (!config.docListProperty) { + return null; + } + + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/docs-view/quick-actions.tsx b/packages/frontend/core/src/components/explorer/docs-view/quick-actions.tsx new file mode 100644 index 0000000000..918c741a71 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/quick-actions.tsx @@ -0,0 +1,190 @@ +import { + Checkbox, + IconButton, + type IconButtonProps, + useConfirmModal, +} from '@affine/component'; +import type { DocRecord } from '@affine/core/modules/doc'; +import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { DeleteIcon, OpenInNewIcon, SplitViewIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { memo, useCallback, useContext } from 'react'; + +import { IsFavoriteIcon } from '../../pure/icons'; +import { DocExplorerContext } from '../context'; + +export interface QuickActionProps extends IconButtonProps { + doc: DocRecord; +} + +export const QuickFavorite = memo(function QuickFavorite({ + doc, + ...iconButtonProps +}: QuickActionProps) { + const { quickFavorite } = useContext(DocExplorerContext); + + const favAdapter = useService(CompatibleFavoriteItemsAdapter); + const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc')); + + const toggleFavorite = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + favAdapter.toggle(doc.id, 'doc'); + }, + [doc.id, favAdapter] + ); + + if (!quickFavorite) { + return null; + } + + return ( + } + onClick={toggleFavorite} + {...iconButtonProps} + /> + ); +}); + +export const QuickTab = memo(function QuickTab({ + doc, + ...iconButtonProps +}: QuickActionProps) { + const { quickTab } = useContext(DocExplorerContext); + const workbench = useService(WorkbenchService).workbench; + const onOpenInNewTab = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + workbench.openDoc(doc.id, { at: 'new-tab' }); + }, + [doc.id, workbench] + ); + + if (!quickTab) { + return null; + } + + return ( + } + {...iconButtonProps} + /> + ); +}); + +export const QuickSplit = memo(function QuickSplit({ + doc, + ...iconButtonProps +}: QuickActionProps) { + const { quickSplit } = useContext(DocExplorerContext); + const workbench = useService(WorkbenchService).workbench; + + const onOpenInSplitView = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + track.allDocs.list.docMenu.openInSplitView(); + workbench.openDoc(doc.id, { at: 'tail' }); + }, + [doc.id, workbench] + ); + + if (!quickSplit) { + return null; + } + + return ( + } + {...iconButtonProps} + /> + ); +}); + +export const QuickDelete = memo(function QuickDelete({ + doc, + ...iconButtonProps +}: QuickActionProps) { + const t = useI18n(); + const { openConfirmModal } = useConfirmModal(); + const { quickTrash } = useContext(DocExplorerContext); + + const onMoveToTrash = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!doc) { + return; + } + + track.allDocs.list.docMenu.deleteDoc(); + openConfirmModal({ + title: t['com.affine.moveToTrash.confirmModal.title'](), + description: t['com.affine.moveToTrash.confirmModal.description']({ + title: doc.title$.value || t['Untitled'](), + }), + cancelText: t['com.affine.confirmModal.button.cancel'](), + confirmText: t.Delete(), + confirmButtonOptions: { + variant: 'error', + }, + onConfirm: () => { + doc.moveToTrash(); + }, + }); + }, + [doc, openConfirmModal, t] + ); + + if (!quickTrash) { + return null; + } + + return ( + } + variant="danger" + {...iconButtonProps} + /> + ); +}); + +export const QuickSelect = memo(function QuickSelect({ + doc, + ...iconButtonProps +}: QuickActionProps) { + const { quickSelect, selectedDocIds, onToggleSelect } = + useContext(DocExplorerContext); + + const selected = selectedDocIds.includes(doc.id); + + const onChange = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onToggleSelect(doc.id); + }, + [doc.id, onToggleSelect] + ); + + if (!quickSelect) { + return null; + } + + return ( + } + {...iconButtonProps} + /> + ); +}); diff --git a/packages/frontend/core/src/components/explorer/docs-view/stack-property.tsx b/packages/frontend/core/src/components/explorer/docs-view/stack-property.tsx new file mode 100644 index 0000000000..317ddbb977 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/docs-view/stack-property.tsx @@ -0,0 +1,18 @@ +import * as styles from './properties.css'; + +export const StackProperty = ({ + icon, + children, +}: { + icon: React.ReactNode; + children: React.ReactNode; +}) => { + return ( +
    +
    +
    {icon}
    +
    {children}
    +
    +
    + ); +}; diff --git a/packages/frontend/core/src/components/explorer/quick-actions.constants.tsx b/packages/frontend/core/src/components/explorer/quick-actions.constants.tsx new file mode 100644 index 0000000000..7c5beb07c2 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/quick-actions.constants.tsx @@ -0,0 +1,52 @@ +import type { I18nString } from '@affine/i18n'; + +import { + type QuickActionProps, + QuickDelete, + QuickFavorite, + QuickSelect, + QuickSplit, + QuickTab, +} from './docs-view/quick-actions'; +import type { ExplorerPreference } from './types'; + +export interface QuickAction { + name: I18nString; + Component: React.FC; + disabled?: boolean; +} + +type ExtractPrefixKeys = { + [Key in keyof Obj]-?: Key extends `${Prefix}${string}` ? Key : never; +}[keyof Obj]; + +export type QuickActionKey = ExtractPrefixKeys; + +const QUICK_ACTION_MAP: Record = { + quickFavorite: { + name: 'com.affine.all-docs.quick-action.favorite', + Component: QuickFavorite, + }, + quickTrash: { + name: 'com.affine.all-docs.quick-action.trash', + Component: QuickDelete, + }, + quickSplit: { + name: 'com.affine.all-docs.quick-action.split', + Component: QuickSplit, + disabled: !BUILD_CONFIG.isElectron, + }, + quickTab: { + name: 'com.affine.all-docs.quick-action.tab', + Component: QuickTab, + }, + quickSelect: { + name: 'com.affine.all-docs.quick-action.select', + Component: QuickSelect, + }, +}; +export const quickActions = Object.entries(QUICK_ACTION_MAP).map( + ([key, config]) => { + return { key: key as QuickActionKey, ...config }; + } +); diff --git a/packages/frontend/core/src/components/explorer/types.ts b/packages/frontend/core/src/components/explorer/types.ts index 51604c45d6..c8609eba18 100644 --- a/packages/frontend/core/src/components/explorer/types.ts +++ b/packages/frontend/core/src/components/explorer/types.ts @@ -3,9 +3,31 @@ import type { GroupByParams, OrderByParams, } from '@affine/core/modules/collection-rules/types'; +import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; +import type { DocRecord } from '@affine/core/modules/doc'; export interface ExplorerPreference { filters?: FilterParams[]; groupBy?: GroupByParams; orderBy?: OrderByParams; + displayProperties?: string[]; + showDocIcon?: boolean; + showDocPreview?: boolean; + quickFavorite?: boolean; + quickTrash?: boolean; + quickSplit?: boolean; + quickTab?: boolean; + quickSelect?: boolean; +} + +export interface DocListPropertyProps { + value: any; + doc: DocRecord; + propertyInfo: DocCustomPropertyInfo; +} + +export interface GroupHeaderProps { + groupId: string; + docCount: number; + collapsed: boolean; } diff --git a/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx b/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx index 31965ca32c..78b1c82e16 100644 --- a/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx @@ -1,7 +1,12 @@ import { Checkbox, Menu, MenuItem, PropertyValue } from '@affine/component'; import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { useI18n } from '@affine/i18n'; +import { CheckBoxCheckLinearIcon } from '@blocksuite/icons/rc'; import { useCallback } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import * as styles from './checkbox.css'; @@ -73,3 +78,33 @@ export const CheckboxFilterValue = ({ ); }; + +export const CheckboxDocListProperty = ({ + value, + propertyInfo, +}: DocListPropertyProps) => { + const t = useI18n(); + if (!value) return null; + + return ( + }> + {/* + Has circular dependency issue (WorkspacePropertyName -> WorkspacePropertyTypes -> Checkbox) + + */} + {propertyInfo.name || t['unnamed']()} + + ); +}; + +export const CheckboxGroupHeader = ({ + groupId, + docCount, +}: GroupHeaderProps) => { + const text = groupId === 'true' ? 'Checked' : 'Unchecked'; + return ( + + {text} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/created-updated-by.css.ts b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.css.ts index b7bb7aa74c..cd726f627c 100644 --- a/packages/frontend/core/src/components/workspace-property-types/created-updated-by.css.ts +++ b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.css.ts @@ -3,3 +3,9 @@ export const userWrapper = style({ display: 'flex', gap: '8px', }); + +export const userLabelContainer = style({ + height: '100%', + display: 'flex', + alignItems: 'center', +}); diff --git a/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx index 852a08bb46..a4d4c75267 100644 --- a/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx @@ -1,34 +1,47 @@ import { PropertyValue } from '@affine/component'; import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user'; import type { FilterParams } from '@affine/core/modules/collection-rules'; -import { DocService } from '@affine/core/modules/doc'; +import { type DocRecord, DocService } from '@affine/core/modules/doc'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; -import { useCallback, useMemo } from 'react'; +import { type ReactNode, useCallback, useMemo } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import { MemberSelectorInline } from '../member-selector'; -import { userWrapper } from './created-updated-by.css'; +import * as styles from './created-updated-by.css'; const CreatedByUpdatedByAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy'; + doc: DocRecord; + size?: number; + showName?: boolean; + emptyFallback?: ReactNode; }) => { - const docService = useService(DocService); + const doc = props.doc; + const userId = useLiveData( - props.type === 'CreatedBy' - ? docService.doc.createdBy$ - : docService.doc.updatedBy$ + props.type === 'CreatedBy' ? doc?.createdBy$ : doc?.updatedBy$ ); if (userId) { return ( -
    - +
    +
    ); } - return ; + return props.emptyFallback === undefined ? ( + + ) : ( + props.emptyFallback + ); }; const NoRecordValue = () => { @@ -46,6 +59,7 @@ const LocalUserValue = () => { }; export const CreatedByValue = () => { + const doc = useService(DocService).doc.record; const workspaceService = useService(WorkspaceService); const isCloud = workspaceService.workspace.flavour !== 'local'; @@ -59,12 +73,13 @@ export const CreatedByValue = () => { return ( - + ); }; export const UpdatedByValue = () => { + const doc = useService(DocService).doc.record; const workspaceService = useService(WorkspaceService); const isCloud = workspaceService.workspace.flavour !== 'local'; @@ -78,7 +93,7 @@ export const UpdatedByValue = () => { return ( - + ); }; @@ -119,3 +134,45 @@ export const CreatedByUpdatedByFilterValue = ({ /> ); }; + +export const CreatedByDocListInlineProperty = ({ + doc, +}: DocListPropertyProps) => { + return ( + + ); +}; + +export const UpdatedByDocListInlineProperty = ({ + doc, +}: DocListPropertyProps) => { + return ( + + ); +}; + +export const ModifiedByGroupHeader = ({ + groupId, + docCount, +}: GroupHeaderProps) => { + const userId = groupId; + + return ( + +
    + +
    +
    + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/date.css.ts b/packages/frontend/core/src/components/workspace-property-types/date.css.ts index 8d1ec3d37e..748939ec84 100644 --- a/packages/frontend/core/src/components/workspace-property-types/date.css.ts +++ b/packages/frontend/core/src/components/workspace-property-types/date.css.ts @@ -1,6 +1,28 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const empty = style({ color: cssVar('placeholderColor'), }); + +export const dateDocListInlineProperty = style({ + width: 60, + textAlign: 'center', + fontSize: 12, + lineHeight: '20px', + color: cssVarV2.text.secondary, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + flexShrink: 0, +}); + +export const tooltip = style({ + display: 'inline-block', + selectors: { + '&::first-letter': { + textTransform: 'uppercase', + }, + }, +}); diff --git a/packages/frontend/core/src/components/workspace-property-types/date.tsx b/packages/frontend/core/src/components/workspace-property-types/date.tsx index 66e2d9655b..ec45a299bb 100644 --- a/packages/frontend/core/src/components/workspace-property-types/date.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/date.tsx @@ -2,10 +2,14 @@ import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component'; import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; import { i18nTime, useI18n } from '@affine/i18n'; +import { DateTimeIcon } from '@blocksuite/icons/rc'; import { useLiveData, useServices } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; import { useCallback } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import * as styles from './date.css'; @@ -184,3 +188,71 @@ export const DateFilterValue = ({ ) : undefined; }; + +export const DateDocListProperty = ({ value }: DocListPropertyProps) => { + if (!value) return null; + + return ( + }> + {i18nTime(value, { absolute: { accuracy: 'day' } })} + + ); +}; + +export const CreateDateDocListProperty = ({ doc }: DocListPropertyProps) => { + const t = useI18n(); + const docMeta = useLiveData(doc.meta$); + const createDate = docMeta?.createDate; + + if (!createDate) return null; + + return ( + + {t.t('created at', { time: i18nTime(createDate) })} + + } + > +
    + {i18nTime(createDate, { relative: true })} +
    +
    + ); +}; + +export const UpdatedDateDocListProperty = ({ doc }: DocListPropertyProps) => { + const t = useI18n(); + const docMeta = useLiveData(doc.meta$); + const updatedDate = docMeta?.updatedDate; + + if (!updatedDate) return null; + + return ( + + {t.t('updated at', { time: i18nTime(updatedDate) })} + + } + > +
    + {i18nTime(updatedDate, { relative: true })} +
    +
    + ); +}; + +export const DateGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { + const date = groupId || 'No Date'; + + return ( + + {date} + + ); +}; + +export const CreatedGroupHeader = (props: GroupHeaderProps) => { + return ; +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx index 4d782d53e7..6bba38df11 100644 --- a/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx @@ -9,9 +9,13 @@ import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; import { useI18n } from '@affine/i18n'; import type { DocMode } from '@blocksuite/affine/model'; +import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import { PropertyRadioGroup } from '../properties/widgets/radio-group'; import * as styles from './doc-primary-mode.css'; @@ -114,3 +118,37 @@ export const DocPrimaryModeFilterValue = ({ ); }; + +export const DocPrimaryModeDocListProperty = ({ + doc, +}: DocListPropertyProps) => { + const t = useI18n(); + const primaryMode = useLiveData(doc.primaryMode$); + + return ( + : } + > + {primaryMode === 'edgeless' ? t['Edgeless']() : t['Page']()} + + ); +}; + +export const DocPrimaryModeGroupHeader = ({ + groupId, + docCount, +}: GroupHeaderProps) => { + const t = useI18n(); + const text = + groupId === 'edgeless' + ? t['com.affine.edgelessMode']() + : groupId === 'page' + ? t['com.affine.pageMode']() + : 'Default'; + + return ( + + {text} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx b/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx index 1fdfb14ef5..21e5e635e9 100644 --- a/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx @@ -1,9 +1,12 @@ import { PropertyValue, type RadioItem } from '@affine/component'; import { DocService } from '@affine/core/modules/doc'; import { useI18n } from '@affine/i18n'; +import { EdgelessIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import { PropertyRadioGroup } from '../properties/widgets/radio-group'; import * as styles from './edgeless-theme.css'; @@ -56,3 +59,20 @@ export const EdgelessThemeValue = ({ ); }; + +export const EdgelessThemeDocListProperty = ({ doc }: DocListPropertyProps) => { + const t = useI18n(); + const edgelessTheme = useLiveData( + doc.properties$.selector(p => p.edgelessColorTheme) + ); + + return ( + }> + {edgelessTheme === 'system' || !edgelessTheme + ? t['com.affine.themeSettings.auto']() + : edgelessTheme === 'light' + ? t['com.affine.themeSettings.light']() + : t['com.affine.themeSettings.dark']()} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/index.ts b/packages/frontend/core/src/components/workspace-property-types/index.ts index 9433af1627..ce634c30bf 100644 --- a/packages/frontend/core/src/components/workspace-property-types/index.ts +++ b/packages/frontend/core/src/components/workspace-property-types/index.ts @@ -20,30 +20,64 @@ import { TodayIcon, } from '@blocksuite/icons/rc'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; -import { CheckboxFilterValue, CheckboxValue } from './checkbox'; import { + CheckboxDocListProperty, + CheckboxFilterValue, + CheckboxGroupHeader, + CheckboxValue, +} from './checkbox'; +import { + CreatedByDocListInlineProperty, CreatedByUpdatedByFilterValue, CreatedByValue, + ModifiedByGroupHeader, + UpdatedByDocListInlineProperty, UpdatedByValue, } from './created-updated-by'; import { + CreateDateDocListProperty, CreateDateValue, + CreatedGroupHeader, + DateDocListProperty, DateFilterValue, + DateGroupHeader, DateValue, + UpdatedDateDocListProperty, UpdatedDateValue, } from './date'; import { + DocPrimaryModeDocListProperty, DocPrimaryModeFilterValue, + DocPrimaryModeGroupHeader, DocPrimaryModeValue, } from './doc-primary-mode'; -import { EdgelessThemeValue } from './edgeless-theme'; -import { JournalFilterValue, JournalValue } from './journal'; -import { NumberValue } from './number'; -import { PageWidthValue } from './page-width'; -import { TagsFilterValue, TagsValue } from './tags'; -import { TemplateValue } from './template'; -import { TextFilterValue, TextValue } from './text'; +import { + EdgelessThemeDocListProperty, + EdgelessThemeValue, +} from './edgeless-theme'; +import { + JournalDocListProperty, + JournalFilterValue, + JournalGroupHeader, + JournalValue, +} from './journal'; +import { NumberDocListProperty, NumberValue } from './number'; +import { PageWidthDocListProperty, PageWidthValue } from './page-width'; +import { + TagsDocListProperty, + TagsFilterValue, + TagsGroupHeader, + TagsValue, +} from './tags'; +import { TemplateDocListProperty, TemplateValue } from './template'; +import { + TextDocListProperty, + TextFilterValue, + TextGroupHeader, + TextValue, +} from './text'; const DateFilterMethod = { after: 'com.affine.filter.after', @@ -76,6 +110,9 @@ export const WorkspacePropertyTypes = { allowInOrderBy: true, defaultFilter: { method: 'is-not-empty' }, filterValue: TagsFilterValue, + showInDocList: 'inline', + docListProperty: TagsDocListProperty, + groupHeader: TagsGroupHeader, }, text: { icon: TextIcon, @@ -92,12 +129,17 @@ export const WorkspacePropertyTypes = { allowInOrderBy: true, filterValue: TextFilterValue, defaultFilter: { method: 'is-not-empty' }, + showInDocList: 'stack', + docListProperty: TextDocListProperty, + groupHeader: TextGroupHeader, }, number: { icon: NumberIcon, value: NumberValue, name: 'com.affine.page-properties.property.number', description: 'com.affine.page-properties.property.number.tooltips', + showInDocList: 'stack', + docListProperty: NumberDocListProperty, }, checkbox: { icon: CheckBoxCheckLinearIcon, @@ -112,6 +154,9 @@ export const WorkspacePropertyTypes = { allowInOrderBy: true, filterValue: CheckboxFilterValue, defaultFilter: { method: 'is', value: 'true' }, + showInDocList: 'stack', + docListProperty: CheckboxDocListProperty, + groupHeader: CheckboxGroupHeader, }, date: { icon: DateTimeIcon, @@ -127,6 +172,9 @@ export const WorkspacePropertyTypes = { allowInOrderBy: true, filterValue: DateFilterValue, defaultFilter: { method: 'is-not-empty' }, + showInDocList: 'stack', + docListProperty: DateDocListProperty, + groupHeader: DateGroupHeader, }, createdBy: { icon: MemberIcon, @@ -140,6 +188,9 @@ export const WorkspacePropertyTypes = { }, filterValue: CreatedByUpdatedByFilterValue, defaultFilter: { method: 'include', value: '' }, + showInDocList: 'inline', + docListProperty: CreatedByDocListInlineProperty, + groupHeader: ModifiedByGroupHeader, }, updatedBy: { icon: MemberIcon, @@ -153,6 +204,9 @@ export const WorkspacePropertyTypes = { }, filterValue: CreatedByUpdatedByFilterValue, defaultFilter: { method: 'include', value: '' }, + showInDocList: 'inline', + docListProperty: UpdatedByDocListInlineProperty, + groupHeader: ModifiedByGroupHeader, }, updatedAt: { icon: DateTimeIcon, @@ -167,6 +221,8 @@ export const WorkspacePropertyTypes = { }, filterValue: DateFilterValue, defaultFilter: { method: 'this-week' }, + showInDocList: 'inline', + docListProperty: UpdatedDateDocListProperty, }, createdAt: { icon: HistoryIcon, @@ -181,6 +237,9 @@ export const WorkspacePropertyTypes = { }, filterValue: DateFilterValue, defaultFilter: { method: 'this-week' }, + showInDocList: 'inline', + docListProperty: CreateDateDocListProperty, + groupHeader: CreatedGroupHeader, }, docPrimaryMode: { icon: FileIcon, @@ -195,6 +254,9 @@ export const WorkspacePropertyTypes = { }, filterValue: DocPrimaryModeFilterValue, defaultFilter: { method: 'is', value: 'page' }, + showInDocList: 'stack', + docListProperty: DocPrimaryModeDocListProperty, + groupHeader: DocPrimaryModeGroupHeader, }, journal: { icon: TodayIcon, @@ -209,18 +271,25 @@ export const WorkspacePropertyTypes = { }, filterValue: JournalFilterValue, defaultFilter: { method: 'is', value: 'true' }, + showInDocList: 'stack', + docListProperty: JournalDocListProperty, + groupHeader: JournalGroupHeader, }, edgelessTheme: { icon: EdgelessIcon, value: EdgelessThemeValue, name: 'com.affine.page-properties.property.edgelessTheme', description: 'com.affine.page-properties.property.edgelessTheme.tooltips', + showInDocList: 'stack', + docListProperty: EdgelessThemeDocListProperty, }, pageWidth: { icon: LongerIcon, value: PageWidthValue, name: 'com.affine.page-properties.property.pageWidth', description: 'com.affine.page-properties.property.pageWidth.tooltips', + showInDocList: 'stack', + docListProperty: PageWidthDocListProperty, }, template: { icon: TemplateIcon, @@ -228,6 +297,8 @@ export const WorkspacePropertyTypes = { name: 'com.affine.page-properties.property.template', renameable: true, description: 'com.affine.page-properties.property.template.tooltips', + showInDocList: 'stack', + docListProperty: TemplateDocListProperty, }, unknown: { icon: PropertyIcon, @@ -254,6 +325,14 @@ export const WorkspacePropertyTypes = { name: I18nString; renameable?: boolean; description?: I18nString; + /** + * Whether to show the property in the doc list, + * - `inline`: show the property in the doc list inline + * - `stack`: show as tags + */ + showInDocList?: 'inline' | 'stack'; + docListProperty?: React.FC; + groupHeader?: React.FC; }; }; diff --git a/packages/frontend/core/src/components/workspace-property-types/journal.tsx b/packages/frontend/core/src/components/workspace-property-types/journal.tsx index 53bf453980..2112d52440 100644 --- a/packages/frontend/core/src/components/workspace-property-types/journal.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/journal.tsx @@ -12,6 +12,7 @@ import { JournalService } from '@affine/core/modules/journal'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { ViewService } from '@affine/core/modules/workbench/services/view'; import { i18nTime, useI18n } from '@affine/i18n'; +import { TodayIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService, @@ -20,6 +21,9 @@ import { import dayjs from 'dayjs'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import * as styles from './journal.css'; @@ -216,3 +220,27 @@ export const JournalFilterValue = ({ ); }; + +export const JournalDocListProperty = ({ doc }: DocListPropertyProps) => { + const journalService = useService(JournalService); + const journalDate = useLiveData(journalService.journalDate$(doc.id)); + + if (!journalDate) { + return null; + } + + return ( + }> + {i18nTime(journalDate, { absolute: { accuracy: 'day' } })} + + ); +}; + +export const JournalGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { + const text = groupId === 'true' ? 'Journal' : 'Not Journal'; + return ( + + {text} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/number.tsx b/packages/frontend/core/src/components/workspace-property-types/number.tsx index 8dc4367482..4c6a1cd9e8 100644 --- a/packages/frontend/core/src/components/workspace-property-types/number.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/number.tsx @@ -1,5 +1,6 @@ import { PropertyValue } from '@affine/component'; import { useI18n } from '@affine/i18n'; +import { NumberIcon } from '@blocksuite/icons/rc'; import { type ChangeEventHandler, useCallback, @@ -7,6 +8,7 @@ import { useState, } from 'react'; +import { StackProperty } from '../explorer/docs-view/stack-property'; import type { PropertyValueProps } from '../properties/types'; import * as styles from './number.css'; @@ -55,3 +57,11 @@ export const NumberValue = ({ ); }; + +export const NumberDocListProperty = ({ value }: { value: number }) => { + if (value !== 0 && !value) { + return null; + } + + return }>{value}; +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/page-width.tsx b/packages/frontend/core/src/components/workspace-property-types/page-width.tsx index 7f688d05a1..a92b8da012 100644 --- a/packages/frontend/core/src/components/workspace-property-types/page-width.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/page-width.tsx @@ -2,9 +2,12 @@ import { PropertyValue, type RadioItem } from '@affine/component'; import { DocService } from '@affine/core/modules/doc'; import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { useI18n } from '@affine/i18n'; +import { LongerIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import { PropertyRadioGroup } from '../properties/widgets/radio-group'; import { container } from './page-width.css'; @@ -58,3 +61,20 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => { ); }; + +export const PageWidthDocListProperty = ({ doc }: DocListPropertyProps) => { + const t = useI18n(); + const pageWidth = useLiveData(doc.properties$.selector(p => p.pageWidth)); + + return ( + }> + {pageWidth === 'standard' || !pageWidth + ? t[ + 'com.affine.settings.editorSettings.page.default-page-width.standard' + ]() + : t[ + 'com.affine.settings.editorSettings.page.default-page-width.full-width' + ]()} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/tags.css.ts b/packages/frontend/core/src/components/workspace-property-types/tags.css.ts index 855113d628..780e36b94b 100644 --- a/packages/frontend/core/src/components/workspace-property-types/tags.css.ts +++ b/packages/frontend/core/src/components/workspace-property-types/tags.css.ts @@ -5,3 +5,22 @@ export const tagInlineEditor = style({ }); export const container = style({}); + +export const groupHeader = style({ + display: 'flex', + alignItems: 'center', + gap: 8, +}); +export const groupHeaderIcon = style({ + width: 24, + height: 24, + fontSize: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); +export const groupHeaderLabel = style({ + display: 'flex', + alignItems: 'center', + gap: 4, +}); diff --git a/packages/frontend/core/src/components/workspace-property-types/tags.tsx b/packages/frontend/core/src/components/workspace-property-types/tags.tsx index 1210494bae..4db7d6e101 100644 --- a/packages/frontend/core/src/components/workspace-property-types/tags.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/tags.tsx @@ -1,7 +1,7 @@ import { PropertyValue } from '@affine/component'; import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; -import { TagService } from '@affine/core/modules/tag'; +import { type Tag, TagService } from '@affine/core/modules/tag'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; import { TagsIcon } from '@blocksuite/icons/rc'; @@ -9,6 +9,9 @@ import { useLiveData, useService } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; import { useCallback, useMemo } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import type { PropertyValueProps } from '../properties/types'; import { @@ -164,3 +167,73 @@ const TagsInlineEditor = ({ /> ); }; + +const TagName = ({ tag }: { tag: Tag }) => { + const name = useLiveData(tag.value$); + return name; +}; +const TagIcon = ({ tag, size = 8 }: { tag: Tag; size?: number }) => { + const color = useLiveData(tag.color$); + return ( +
    + ); +}; +export const TagsDocListProperty = ({ doc }: DocListPropertyProps) => { + const tagList = useService(TagService).tagList; + const tags = useLiveData(tagList.tagsByPageId$(doc.id)); + + return ( + <> + {tags.map(tag => { + return ( + } key={tag.id}> + + + ); + })} + + ); +}; + +export const TagsGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { + const t = useI18n(); + const tagService = useService(TagService); + const tag = useLiveData(tagService.tagList.tagByTagId$(groupId)); + + if (!tag) { + return ( + + } + > + {t['com.affine.page.display.grouping.group-by-tag.untagged']()} + + ); + } + return ( + } + > + + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/template.tsx b/packages/frontend/core/src/components/workspace-property-types/template.tsx index 3d9eb64f43..75ed3d16b0 100644 --- a/packages/frontend/core/src/components/workspace-property-types/template.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/template.tsx @@ -1,8 +1,12 @@ import { Checkbox, PropertyValue } from '@affine/component'; import { DocService } from '@affine/core/modules/doc'; +import { useI18n } from '@affine/i18n'; +import { TemplateIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { type ChangeEvent, useCallback } from 'react'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { DocListPropertyProps } from '../explorer/types'; import type { PropertyValueProps } from '../properties/types'; import * as styles from './template.css'; @@ -39,3 +43,16 @@ export const TemplateValue = ({ readonly }: PropertyValueProps) => { ); }; + +export const TemplateDocListProperty = ({ doc }: DocListPropertyProps) => { + const t = useI18n(); + const isTemplate = useLiveData(doc.properties$.selector(p => p.isTemplate)); + + if (!isTemplate) { + return null; + } + + return ( + }>{t['Template']()} + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/text.tsx b/packages/frontend/core/src/components/workspace-property-types/text.tsx index 0693fcd22d..364e3a474c 100644 --- a/packages/frontend/core/src/components/workspace-property-types/text.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/text.tsx @@ -1,7 +1,7 @@ import { Input, Menu, PropertyValue } from '@affine/component'; import type { FilterParams } from '@affine/core/modules/collection-rules'; import { useI18n } from '@affine/i18n'; -import { TextIcon } from '@blocksuite/icons/rc'; +import { TextIcon, TextTypeIcon } from '@blocksuite/icons/rc'; import { cssVar } from '@toeverything/theme'; import { cssVarV2 } from '@toeverything/theme/v2'; import { @@ -12,6 +12,9 @@ import { useState, } from 'react'; +import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; +import { StackProperty } from '../explorer/docs-view/stack-property'; +import type { GroupHeaderProps } from '../explorer/types'; import { ConfigModal } from '../mobile'; import type { PropertyValueProps } from '../properties/types'; import * as styles from './text.css'; @@ -248,3 +251,20 @@ export const TextFilterValue = ({ ) : null; }; + +export const TextDocListProperty = ({ value }: { value: string }) => { + if (!value) { + return null; + } + + return }>{value}; +}; + +export const TextGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { + const text = groupId || 'No Text'; + return ( + + {text} + + ); +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts index c9d274c76a..366c5522de 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts @@ -1,3 +1,4 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const scrollContainer = style({ flex: 1, @@ -27,3 +28,17 @@ export const body = style({ height: '100%', width: '100%', }); + +export const scrollArea = style({ + height: 0, + flex: 1, +}); + +// group +export const groupHeader = style({ + background: cssVarV2.layer.background.primary, +}); + +export const docItem = style({ + transition: 'width 0.2s ease-in-out', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx index c33f41380b..8718315f0b 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx @@ -1,11 +1,37 @@ +import { + Masonry, + type MasonryGroup, + RadioGroup, + useConfirmModal, +} from '@affine/component'; +import { + DocExplorerContext, + type DocExplorerContextType, +} from '@affine/core/components/explorer/context'; import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu'; +import { + DocListItem, + type DocListItemView, +} from '@affine/core/components/explorer/docs-view/doc-list-item'; import type { ExplorerPreference } from '@affine/core/components/explorer/types'; import { Filters } from '@affine/core/components/filter'; +import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar'; +import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types'; import { CollectionRulesService } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules/types'; -import { useI18n } from '@affine/i18n'; -import { useService } from '@toeverything/infra'; -import { useCallback, useEffect, useState } from 'react'; +import { DocsService } from '@affine/core/modules/doc'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { Trans, useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { ViewBody, @@ -16,14 +42,123 @@ import { import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs'; import * as styles from './all-page.css'; import { MigrationAllDocsDataNotification } from './migration-data'; + +const GroupHeader = memo(function GroupHeader({ + groupId, + collapsed, + itemCount, +}: { + groupId: string; + collapsed?: boolean; + itemCount: number; +}) { + const { groupBy } = useContext(DocExplorerContext); + const propertyService = useService(WorkspacePropertyService); + const allProperties = useLiveData(propertyService.sortedProperties$); + + const groupType = groupBy?.type; + const groupKey = groupBy?.key; + + const header = useMemo(() => { + if (groupType === 'property') { + const property = allProperties.find(p => p.id === groupKey); + if (!property) return null; + + const config = WorkspacePropertyTypes[property.type]; + if (!config?.groupHeader) return null; + return ( + + ); + } else { + return '// TODO: ' + groupType; + } + }, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]); + + if (!groupType) { + return null; + } + + return header; +}); + +const calcCardHeightById = (id: string) => { + const max = 5; + const min = 1; + const code = id.charCodeAt(0); + const value = Math.floor((code % (max - min)) + min); + return 250 + value * 10; +}; + +const DocListItemComponent = memo(function DocListItemComponent({ + itemId, + groupId, +}: { + groupId: string; + itemId: string; +}) { + return ; +}); + export const AllPage = () => { const t = useI18n(); + const docsService = useService(DocsService); + const [view, setView] = useState('masonry'); + const [collapsedGroups, setCollapsedGroups] = useState([]); + const [selectMode, setSelectMode] = useState(false); + const [selectedDocIds, setSelectedDocIds] = useState([]); + const [prevCheckAnchorId, setPrevCheckAnchorId] = useState( + null + ); const [explorerPreference, setExplorerPreference] = - useState({}); + useState({ + filters: [ + { + type: 'system', + key: 'trash', + value: 'false', + method: 'is', + }, + ], + displayProperties: [], + showDocIcon: true, + showDocPreview: true, + }); const [groups, setGroups] = useState([]); + const { openConfirmModal } = useConfirmModal(); + + const masonryItems = useMemo(() => { + const items = groups.map((group: any) => { + return { + id: group.key, + Component: groups.length > 1 ? GroupHeader : undefined, + height: groups.length > 1 ? 24 : 0, + className: styles.groupHeader, + items: group.items.map((docId: string) => { + return { + id: docId, + Component: DocListItemComponent, + height: + view === 'list' + ? 42 + : view === 'grid' + ? 280 + : calcCardHeightById(docId), + 'data-view': view, + className: styles.docItem, + }; + }), + } satisfies MasonryGroup; + }); + return items; + }, [groups, view]); + const collectionRulesService = useService(CollectionRulesService); useEffect(() => { const subscription = collectionRulesService @@ -43,7 +178,26 @@ export const AllPage = () => { return () => { subscription.unsubscribe(); }; - }, [collectionRulesService, explorerPreference]); + }, [ + collectionRulesService, + explorerPreference.filters, + explorerPreference.groupBy, + explorerPreference.orderBy, + ]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSelectMode(false); + setSelectedDocIds([]); + setPrevCheckAnchorId(null); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, []); const handleFilterChange = useCallback((filters: FilterParams[]) => { setExplorerPreference(prev => ({ @@ -51,8 +205,92 @@ export const AllPage = () => { filters, })); }, []); + + const toggleGroupCollapse = useCallback((groupId: string) => { + setCollapsedGroups(prev => { + return prev.includes(groupId) + ? prev.filter(id => id !== groupId) + : [...prev, groupId]; + }); + }, []); + const toggleDocSelect = useCallback((docId: string) => { + setSelectMode(true); + setSelectedDocIds(prev => { + return prev.includes(docId) + ? prev.filter(id => id !== docId) + : [...prev, docId]; + }); + }, []); + const onSelect = useCallback( + (...args: Parameters) => { + setSelectMode(true); + setSelectedDocIds(...args); + }, + [] + ); + const handleCloseFloatingToolbar = useCallback(() => { + setSelectMode(false); + setSelectedDocIds([]); + }, []); + const handleMultiDelete = useCallback(() => { + if (selectedDocIds.length === 0) { + return; + } + + openConfirmModal({ + title: t['com.affine.moveToTrash.confirmModal.title.multiple']({ + number: selectedDocIds.length.toString(), + }), + description: t[ + 'com.affine.moveToTrash.confirmModal.description.multiple' + ]({ + number: selectedDocIds.length.toString(), + }), + cancelText: t['com.affine.confirmModal.button.cancel'](), + confirmText: t.Delete(), + confirmButtonOptions: { + variant: 'error', + }, + onConfirm: () => { + for (const docId of selectedDocIds) { + const doc = docsService.list.doc$(docId).value; + doc?.moveToTrash(); + } + }, + }); + }, [docsService.list, openConfirmModal, selectedDocIds, t]); + + const explorerContextValue = useMemo( + () => + ({ + ...explorerPreference, + view, + groups, + collapsed: collapsedGroups, + selectedDocIds, + selectMode, + prevCheckAnchorId, + setPrevCheckAnchorId, + onToggleCollapse: toggleGroupCollapse, + onToggleSelect: toggleDocSelect, + onSelect, + }) satisfies DocExplorerContextType, + [ + collapsedGroups, + explorerPreference, + groups, + onSelect, + prevCheckAnchorId, + selectMode, + selectedDocIds, + toggleDocSelect, + toggleGroupCollapse, + view, + ] + ); + return ( - <> + @@ -60,6 +298,12 @@ export const AllPage = () => {
    + { onChange={setExplorerPreference} />
    -
    {JSON.stringify(explorerPreference, null, 2)}
    -
    {JSON.stringify(groups, null, 2)}
    +
    + (w > 500 ? 24 : w > 393 ? 20 : 16), + [] + )} + /> +
    + +
    + {{ count: selectedDocIds.length } as any} +
    + selected + + } + /> - +
    ); }; diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 89ec23d298..1898a3d531 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -38,8 +38,6 @@ export type { ServerConfig } from './types'; // eslint-disable-next-line simple-import-sort/imports import { type Framework } from '@toeverything/infra'; -import { DocScope } from '../doc/scopes/doc'; -import { DocService } from '../doc/services/doc'; import { GlobalCache, GlobalState } from '../storage/providers/global'; import { GlobalStateService } from '../storage/services/global'; import { UrlService } from '../url'; @@ -101,7 +99,7 @@ import { DocCreatedByService } from './services/doc-created-by'; import { DocUpdatedByService } from './services/doc-updated-by'; import { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync'; import { WorkspacePermissionService } from '../permissions'; -import { DocsService } from '../doc'; +import { DocScope, DocService, DocsService } from '../doc'; import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync'; export function configureCloudModule(framework: Framework) { diff --git a/packages/frontend/core/src/modules/cloud/views/public-user.tsx b/packages/frontend/core/src/modules/cloud/views/public-user.tsx index 29af2028fc..bc676cb589 100644 --- a/packages/frontend/core/src/modules/cloud/views/public-user.tsx +++ b/packages/frontend/core/src/modules/cloud/views/public-user.tsx @@ -7,7 +7,15 @@ import { useLayoutEffect, useMemo } from 'react'; import { PublicUserService } from '../services/public-user'; import * as styles from './public-user.css'; -export const PublicUserLabel = ({ id }: { id: string }) => { +export const PublicUserLabel = ({ + id, + size = 20, + showName = true, +}: { + id: string; + size?: number; + showName?: boolean; +}) => { const serverService = useCurrentServerService(); const publicUser = useMemo(() => { return serverService?.scope.get(PublicUserService); @@ -28,10 +36,16 @@ export const PublicUserLabel = ({ id }: { id: string }) => { } if (user?.removed) { - return ( + return showName ? ( {t['Unknown User']()} + ) : ( + ); } @@ -40,10 +54,10 @@ export const PublicUserLabel = ({ id }: { id: string }) => { - {user?.name} + {showName && user?.name} ); }; diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts index 412ac19b47..1da0649a82 100644 --- a/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts @@ -25,7 +25,7 @@ export class PropertyFilterProvider extends Service implements FilterProvider { FilterProvider('property:' + type) ); if (!provider) { - throw new Error('Unsupported property type'); + throw new Error(`Unsupported property type: ${type}`); } return provider.filter$(params); }) diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/trash.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/trash.ts new file mode 100644 index 0000000000..afc7012965 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/trash.ts @@ -0,0 +1,21 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class TrashFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + filter$(params: FilterParams): Observable> { + if (params.value === 'true') { + return this.docsService.allTrashDocIds$().pipe(map(ids => new Set(ids))); + } else { + return this.docsService + .allNonTrashDocIds$() + .pipe(map(ids => new Set(ids))); + } + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/index.ts b/packages/frontend/core/src/modules/collection-rules/index.ts index 08b651ede9..35646485c1 100644 --- a/packages/frontend/core/src/modules/collection-rules/index.ts +++ b/packages/frontend/core/src/modules/collection-rules/index.ts @@ -14,6 +14,7 @@ import { PropertyFilterProvider } from './impls/filters/property'; import { SystemFilterProvider } from './impls/filters/system'; import { TagsFilterProvider } from './impls/filters/tags'; import { TextPropertyFilterProvider } from './impls/filters/text'; +import { TrashFilterProvider } from './impls/filters/trash'; import { UpdatedAtFilterProvider } from './impls/filters/updated-at'; import { UpdatedByFilterProvider } from './impls/filters/updated-by'; import { CheckboxPropertyGroupByProvider } from './impls/group-by/checkbox'; @@ -79,6 +80,7 @@ export function configureCollectionRulesModule(framework: Framework) { DocPrimaryModeFilterProvider, [DocsService] ) + .impl(FilterProvider('system:trash'), TrashFilterProvider, [DocsService]) .impl(FilterProvider('property:date'), DatePropertyFilterProvider, [ DocsService, ]) diff --git a/packages/frontend/core/src/modules/doc/services/docs.ts b/packages/frontend/core/src/modules/doc/services/docs.ts index 106286ba6c..a19270305d 100644 --- a/packages/frontend/core/src/modules/doc/services/docs.ts +++ b/packages/frontend/core/src/modules/doc/services/docs.ts @@ -69,6 +69,14 @@ export class DocsService extends Service { return this.store.watchAllDocTagIds(); } + allNonTrashDocIds$() { + return this.store.watchNonTrashDocIds(); + } + + allTrashDocIds$() { + return this.store.watchTrashDocIds(); + } + constructor( private readonly store: DocsStore, private readonly docPropertiesStore: DocPropertiesStore, diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 4ad4315d86..e15dcdce81 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,26 +1,26 @@ { - "ar": 97, + "ar": 96, "ca": 4, "da": 4, - "de": 97, - "el-GR": 97, + "de": 96, + "el-GR": 96, "en": 100, - "es-AR": 97, + "es-AR": 96, "es-CL": 98, - "es": 97, - "fa": 97, - "fr": 97, + "es": 96, + "fa": 96, + "fr": 96, "hi": 2, - "it-IT": 97, + "it-IT": 96, "it": 1, - "ja": 97, + "ja": 96, "ko": 56, - "pl": 97, - "pt-BR": 97, - "ru": 97, - "sv-SE": 97, - "uk": 97, + "pl": 96, + "pt-BR": 96, + "ru": 96, + "sv-SE": 96, + "uk": 96, "ur": 2, - "zh-Hans": 97, - "zh-Hant": 97 + "zh-Hans": 96, + "zh-Hant": 96 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 513fbf6e34..94dd07d0d7 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -593,6 +593,18 @@ export function useAFFiNEI18N(): { * `current` */ current(): string; + /** + * `created at {{time}}` + */ + ["created at"](options: { + readonly time: string; + }): string; + /** + * `last updated at {{time}}` + */ + ["updated at"](options: { + readonly time: string; + }): string; /** * `Automatically check for new updates periodically.` */ @@ -6908,6 +6920,46 @@ export function useAFFiNEI18N(): { * `Inactive workspace` */ ["com.affine.inactive-workspace"](): string; + /** + * `Display Properties` + */ + ["com.affine.all-docs.display.properties"](): string; + /** + * `List view options` + */ + ["com.affine.all-docs.display.list-view"](): string; + /** + * `Icon` + */ + ["com.affine.all-docs.display.list-view.icon"](): string; + /** + * `Body` + */ + ["com.affine.all-docs.display.list-view.body"](): string; + /** + * `Quick actions` + */ + ["com.affine.all-docs.quick-actions"](): string; + /** + * `Favorite` + */ + ["com.affine.all-docs.quick-action.favorite"](): string; + /** + * `Move to trash` + */ + ["com.affine.all-docs.quick-action.trash"](): string; + /** + * `Open in split view` + */ + ["com.affine.all-docs.quick-action.split"](): string; + /** + * `Open in new tab` + */ + ["com.affine.all-docs.quick-action.tab"](): string; + /** + * `Select checkbox` + */ + ["com.affine.all-docs.quick-action.select"](): string; /** * `core` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 5d84a5b294..59688baa6f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -137,6 +137,8 @@ "Deleted User": "Deleted User", "all": "all", "current": "current", + "created at": "created at {{time}}", + "updated at": "last updated at {{time}}", "com.affine.aboutAFFiNE.autoCheckUpdate.description": "Automatically check for new updates periodically.", "com.affine.aboutAFFiNE.autoCheckUpdate.title": "Check for updates automatically", "com.affine.aboutAFFiNE.autoDownloadUpdate.description": "Automatically download updates (to this device).", @@ -1725,6 +1727,16 @@ "com.affine.inactive": "Inactive", "com.affine.inactive-member": "Inactive member", "com.affine.inactive-workspace": "Inactive workspace", + "com.affine.all-docs.display.properties": "Display Properties", + "com.affine.all-docs.display.list-view": "List view options", + "com.affine.all-docs.display.list-view.icon": "Icon", + "com.affine.all-docs.display.list-view.body": "Body", + "com.affine.all-docs.quick-actions": "Quick actions", + "com.affine.all-docs.quick-action.favorite": "Favorite", + "com.affine.all-docs.quick-action.trash": "Move to trash", + "com.affine.all-docs.quick-action.split": "Open in split view", + "com.affine.all-docs.quick-action.tab": "Open in new tab", + "com.affine.all-docs.quick-action.select": "Select checkbox", "core": "core", "dark": "Dark", "invited you to join": "invited you to join",