feat(core): add pinned collections to all docs (#12269)
This commit is contained in:
parent
6eab1a6cb2
commit
61b99c5934
@ -12,7 +12,9 @@ export type DocExplorerContextType = {
|
||||
selectedDocIds$: LiveData<string[]>;
|
||||
prevCheckAnchorId$?: LiveData<string | null>;
|
||||
} & {
|
||||
[K in keyof ExplorerPreference as `${K}$`]: LiveData<ExplorerPreference[K]>;
|
||||
[K in keyof Omit<ExplorerPreference, 'filters'> as `${K}$`]: LiveData<
|
||||
ExplorerPreference[K]
|
||||
>;
|
||||
};
|
||||
|
||||
export const DocExplorerContext = createContext<DocExplorerContextType>(
|
||||
@ -27,7 +29,6 @@ export const createDocExplorerContext = () =>
|
||||
selectMode$: new LiveData<boolean>(false),
|
||||
selectedDocIds$: new LiveData<string[]>([]),
|
||||
prevCheckAnchorId$: new LiveData<string | null>(null),
|
||||
filters$: new LiveData<ExplorerPreference['filters']>([]),
|
||||
groupBy$: new LiveData<ExplorerPreference['groupBy']>(undefined),
|
||||
orderBy$: new LiveData<ExplorerPreference['orderBy']>(undefined),
|
||||
displayProperties$: new LiveData<ExplorerPreference['displayProperties']>(
|
||||
|
@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
|
||||
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
||||
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { ArrowLeftBigIcon, FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
|
||||
@ -11,8 +11,10 @@ import * as styles from './styles.css';
|
||||
|
||||
export const AddFilterMenu = ({
|
||||
onAdd,
|
||||
onBack,
|
||||
}: {
|
||||
onAdd: (params: FilterParams) => void;
|
||||
onBack?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const workspacePropertyService = useService(WorkspacePropertyService);
|
||||
@ -20,9 +22,17 @@ export const AddFilterMenu = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.variableSelectTitleStyle}>
|
||||
{t['com.affine.filter']()}
|
||||
<div className={styles.selectHeaderContainer}>
|
||||
{onBack && (
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowLeftBigIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<div className={styles.variableSelectTitleStyle}>
|
||||
{t['com.affine.filter']()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AddFilter } from './add-filter';
|
||||
import { Filter } from './filter';
|
||||
@ -6,9 +7,11 @@ import * as styles from './styles.css';
|
||||
|
||||
export const Filters = ({
|
||||
filters,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
filters: FilterParams[];
|
||||
className?: string;
|
||||
onChange?: (filters: FilterParams[]) => void;
|
||||
}) => {
|
||||
const handleDelete = (index: number) => {
|
||||
@ -20,7 +23,7 @@ export const Filters = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={clsx(styles.container, className)}>
|
||||
{filters.map((filter, index) => {
|
||||
return (
|
||||
<Filter
|
||||
|
@ -30,12 +30,24 @@ export const filterItemCloseStyle = style({
|
||||
marginLeft: '4px',
|
||||
});
|
||||
|
||||
export const selectHeaderContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
margin: '2px 2px',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const variableSelectTitleStyle = style({
|
||||
margin: '2px 12px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '22px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
selectors: {
|
||||
'&:first-child': {
|
||||
marginLeft: '12px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const filterTypeItemIcon = style({
|
||||
|
@ -34,6 +34,7 @@ export const body = style({
|
||||
export const scrollArea = style({
|
||||
height: 0,
|
||||
flex: 1,
|
||||
paddingTop: '24px',
|
||||
});
|
||||
|
||||
// group
|
||||
@ -45,7 +46,7 @@ export const docItem = style({
|
||||
transition: 'width 0.2s ease-in-out',
|
||||
});
|
||||
|
||||
export const filterArea = style({
|
||||
export const pinnedCollection = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
@ -60,3 +61,23 @@ export const filterArea = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const filterArea = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
padding: '0 24px',
|
||||
paddingTop: '24px',
|
||||
'@container': {
|
||||
'docs-body (width <= 500px)': {
|
||||
padding: '0 20px',
|
||||
},
|
||||
'docs-body (width <= 393px)': {
|
||||
padding: '0 16px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const filters = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
|
||||
import {
|
||||
Button,
|
||||
Masonry,
|
||||
type MasonryGroup,
|
||||
useConfirmModal,
|
||||
usePromptModal,
|
||||
} from '@affine/component';
|
||||
import {
|
||||
createDocExplorerContext,
|
||||
DocExplorerContext,
|
||||
@ -7,6 +13,10 @@ import { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list
|
||||
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 {
|
||||
CollectionService,
|
||||
PinnedCollectionService,
|
||||
} from '@affine/core/modules/collection';
|
||||
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
|
||||
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
@ -35,6 +45,7 @@ import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
|
||||
import * as styles from './all-page.css';
|
||||
import { AllDocsHeader } from './all-page-header';
|
||||
import { MigrationAllDocsDataNotification } from './migration-data';
|
||||
import { PinnedCollections } from './pinned-collections';
|
||||
|
||||
const GroupHeader = memo(function GroupHeader({
|
||||
groupId,
|
||||
@ -100,11 +111,34 @@ const DocListItemComponent = memo(function DocListItemComponent({
|
||||
export const AllPage = () => {
|
||||
const t = useI18n();
|
||||
const docsService = useService(DocsService);
|
||||
const collectionService = useService(CollectionService);
|
||||
const pinnedCollectionService = useService(PinnedCollectionService);
|
||||
|
||||
const [selectedCollectionId, setSelectedCollectionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const selectedCollection = useLiveData(
|
||||
selectedCollectionId
|
||||
? collectionService.collection$(selectedCollectionId)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// if selected collection is not found, set selected collection id to null
|
||||
if (!selectedCollection && selectedCollectionId) {
|
||||
setSelectedCollectionId(null);
|
||||
}
|
||||
}, [selectedCollection, selectedCollectionId]);
|
||||
|
||||
const selectedCollectionInfo = useLiveData(
|
||||
selectedCollection ? selectedCollection.info$ : null
|
||||
);
|
||||
|
||||
const [tempFilters, setTempFilters] = useState<FilterParams[]>([]);
|
||||
|
||||
const [explorerContextValue] = useState(createDocExplorerContext);
|
||||
|
||||
const view = useLiveData(explorerContextValue.view$);
|
||||
const filters = useLiveData(explorerContextValue.filters$);
|
||||
const groupBy = useLiveData(explorerContextValue.groupBy$);
|
||||
const orderBy = useLiveData(explorerContextValue.orderBy$);
|
||||
const groups = useLiveData(explorerContextValue.groups$);
|
||||
@ -112,8 +146,8 @@ export const AllPage = () => {
|
||||
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
|
||||
const selectMode = useLiveData(explorerContextValue.selectMode$);
|
||||
|
||||
const { openPromptModal } = usePromptModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const masonryItems = useMemo(() => {
|
||||
const items = groups.map((group: any) => {
|
||||
return {
|
||||
@ -143,12 +177,21 @@ export const AllPage = () => {
|
||||
const collectionRulesService = useService(CollectionRulesService);
|
||||
useEffect(() => {
|
||||
const subscription = collectionRulesService
|
||||
.watch({
|
||||
filters:
|
||||
filters && filters.length > 0
|
||||
? filters
|
||||
: [
|
||||
// if no filters are present, match all non-trash documents
|
||||
.watch(
|
||||
// collection filters and temp filters can't exist at the same time
|
||||
selectedCollectionInfo
|
||||
? {
|
||||
filters: selectedCollectionInfo.rules.filters,
|
||||
groupBy,
|
||||
orderBy,
|
||||
extraAllowList: selectedCollectionInfo.allowList,
|
||||
extraFilters: [
|
||||
{
|
||||
type: 'system',
|
||||
key: 'empty-journal',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
@ -156,23 +199,38 @@ export const AllPage = () => {
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
groupBy,
|
||||
orderBy,
|
||||
extraFilters: [
|
||||
{
|
||||
type: 'system',
|
||||
key: 'empty-journal',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
: {
|
||||
filters:
|
||||
tempFilters && tempFilters.length > 0
|
||||
? tempFilters
|
||||
: [
|
||||
// if no filters are present, match all non-trash documents
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
groupBy,
|
||||
orderBy,
|
||||
extraFilters: [
|
||||
{
|
||||
type: 'system',
|
||||
key: 'empty-journal',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
.subscribe({
|
||||
next: result => {
|
||||
explorerContextValue.groups$.next(result.groups);
|
||||
@ -186,10 +244,12 @@ export const AllPage = () => {
|
||||
};
|
||||
}, [
|
||||
collectionRulesService,
|
||||
explorerContextValue.groups$,
|
||||
filters,
|
||||
explorerContextValue,
|
||||
groupBy,
|
||||
orderBy,
|
||||
selectedCollection,
|
||||
selectedCollectionInfo,
|
||||
tempFilters,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -206,12 +266,9 @@ export const AllPage = () => {
|
||||
};
|
||||
}, [explorerContextValue]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(filters: FilterParams[]) => {
|
||||
explorerContextValue.filters$.next(filters);
|
||||
},
|
||||
[explorerContextValue]
|
||||
);
|
||||
const handleFilterChange = useCallback((filters: FilterParams[]) => {
|
||||
setTempFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleCloseFloatingToolbar = useCallback(() => {
|
||||
explorerContextValue.selectMode$.next(false);
|
||||
@ -246,6 +303,42 @@ export const AllPage = () => {
|
||||
});
|
||||
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
|
||||
|
||||
const handleSaveFilters = useCallback(() => {
|
||||
openPromptModal({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
label: t['com.affine.editCollectionName.name'](),
|
||||
inputOptions: {
|
||||
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
|
||||
},
|
||||
children: t['com.affine.editCollectionName.createTips'](),
|
||||
confirmText: t['com.affine.editCollection.save'](),
|
||||
cancelText: t['com.affine.editCollection.button.cancel'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
onConfirm(name) {
|
||||
const id = collectionService.createCollection({
|
||||
name,
|
||||
rules: {
|
||||
filters: tempFilters,
|
||||
},
|
||||
});
|
||||
pinnedCollectionService.addPinnedCollection({
|
||||
collectionId: id,
|
||||
index: pinnedCollectionService.indexAt('after'),
|
||||
});
|
||||
setTempFilters([]);
|
||||
setSelectedCollectionId(id);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
collectionService,
|
||||
openPromptModal,
|
||||
pinnedCollectionService,
|
||||
t,
|
||||
tempFilters,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DocExplorerContext.Provider value={explorerContextValue}>
|
||||
<ViewTitle title={t['All pages']()} />
|
||||
@ -255,10 +348,40 @@ export const AllPage = () => {
|
||||
</ViewHeader>
|
||||
<ViewBody>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.filterArea}>
|
||||
<MigrationAllDocsDataNotification />
|
||||
<Filters filters={filters ?? []} onChange={handleFilterChange} />
|
||||
<MigrationAllDocsDataNotification />
|
||||
<div className={styles.pinnedCollection}>
|
||||
<PinnedCollections
|
||||
activeCollectionId={selectedCollectionId}
|
||||
onClickAll={() => setSelectedCollectionId(null)}
|
||||
onClickCollection={collectionId => {
|
||||
setSelectedCollectionId(collectionId);
|
||||
setTempFilters([]);
|
||||
}}
|
||||
onAddFilter={params => {
|
||||
setSelectedCollectionId(null);
|
||||
setTempFilters([...(tempFilters ?? []), params]);
|
||||
}}
|
||||
hiddenAdd={tempFilters.length > 0}
|
||||
/>
|
||||
</div>
|
||||
{tempFilters.length > 0 && (
|
||||
<div className={styles.filterArea}>
|
||||
<Filters
|
||||
className={styles.filters}
|
||||
filters={tempFilters ?? []}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={() => {
|
||||
setTempFilters([]);
|
||||
}}
|
||||
>
|
||||
{t['Cancel']()}
|
||||
</Button>
|
||||
<Button onClick={handleSaveFilters}>{t['save']()}</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.scrollArea}>
|
||||
<Masonry
|
||||
items={masonryItems}
|
||||
|
@ -6,6 +6,16 @@ export const migrationDataNotificationContainer = style({
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
padding: '12px 240px 12px 12px',
|
||||
borderRadius: '8px',
|
||||
margin: '0 24px',
|
||||
marginTop: '24px',
|
||||
'@container': {
|
||||
'docs-body (width <= 500px)': {
|
||||
margin: '0 20px',
|
||||
},
|
||||
'docs-body (width <= 393px)': {
|
||||
margin: '0 16px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const migrationDataNotificationTitle = style({
|
||||
|
@ -0,0 +1,34 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const item = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
minWidth: '46px',
|
||||
lineHeight: '24px',
|
||||
fontSize: cssVar('fontBase'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
':hover': {
|
||||
color: cssVarV2('text/primary'),
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVarV2('text/primary'),
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
});
|
@ -0,0 +1,180 @@
|
||||
import { Divider, IconButton, Menu, MenuItem } from '@affine/component';
|
||||
import { AddFilterMenu } from '@affine/core/components/filter/add-filter';
|
||||
import {
|
||||
CollectionService,
|
||||
type PinnedCollectionRecord,
|
||||
PinnedCollectionService,
|
||||
} from '@affine/core/modules/collection';
|
||||
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { CollectionsIcon, FilterIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import * as styles from './pinned-collections.css';
|
||||
|
||||
export const PinnedCollectionItem = ({
|
||||
record,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
record: PinnedCollectionRecord;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const collectionService = useService(CollectionService);
|
||||
const collection = useLiveData(
|
||||
collectionService.collection$(record.collectionId)
|
||||
);
|
||||
const name = useLiveData(collection?.name$);
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.item}
|
||||
role="button"
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{name ?? t['Untitled']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PinnedCollections = ({
|
||||
activeCollectionId,
|
||||
onClickAll,
|
||||
onClickCollection,
|
||||
onAddFilter,
|
||||
hiddenAdd,
|
||||
}: {
|
||||
activeCollectionId: string | null;
|
||||
onClickAll: () => void;
|
||||
onClickCollection: (collectionId: string) => void;
|
||||
onAddFilter: (params: FilterParams) => void;
|
||||
hiddenAdd?: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const pinnedCollectionService = useService(PinnedCollectionService);
|
||||
const pinnedCollections = useLiveData(
|
||||
pinnedCollectionService.sortedPinnedCollections$
|
||||
);
|
||||
|
||||
const handleAddPinnedCollection = (collectionId: string) => {
|
||||
pinnedCollectionService.addPinnedCollection({
|
||||
collectionId,
|
||||
index: pinnedCollectionService.indexAt('after'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.item}
|
||||
data-active={activeCollectionId === null ? 'true' : undefined}
|
||||
onClick={onClickAll}
|
||||
role="button"
|
||||
>
|
||||
{t['com.affine.all-docs.pinned-collection.all']()}
|
||||
</div>
|
||||
{pinnedCollections.map(record => (
|
||||
<PinnedCollectionItem
|
||||
key={record.collectionId}
|
||||
record={record}
|
||||
isActive={activeCollectionId === record.collectionId}
|
||||
onClick={() => onClickCollection(record.collectionId)}
|
||||
/>
|
||||
))}
|
||||
{!hiddenAdd && (
|
||||
<AddPinnedCollection
|
||||
onAddPinnedCollection={handleAddPinnedCollection}
|
||||
onAddFilter={onAddFilter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddPinnedCollection = ({
|
||||
onAddPinnedCollection,
|
||||
onAddFilter,
|
||||
}: {
|
||||
onAddPinnedCollection: (collectionId: string) => void;
|
||||
onAddFilter: (params: FilterParams) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<AddPinnedCollectionMenuContent
|
||||
onAddPinnedCollection={onAddPinnedCollection}
|
||||
onAddFilter={onAddFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton size="16">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddPinnedCollectionMenuContent = ({
|
||||
onAddPinnedCollection,
|
||||
onAddFilter,
|
||||
}: {
|
||||
onAddPinnedCollection: (collectionId: string) => void;
|
||||
onAddFilter: (params: FilterParams) => void;
|
||||
}) => {
|
||||
const [addingFilter, setAddingFilter] = useState<boolean>(false);
|
||||
const collectionService = useService(CollectionService);
|
||||
const collectionMetas = useLiveData(collectionService.collectionMetas$);
|
||||
const pinnedCollectionService = useService(PinnedCollectionService);
|
||||
const pinnedCollections = useLiveData(
|
||||
pinnedCollectionService.pinnedCollections$
|
||||
);
|
||||
|
||||
const unpinnedCollectionMetas = useMemo(
|
||||
() =>
|
||||
collectionMetas.filter(
|
||||
meta =>
|
||||
!pinnedCollections.some(
|
||||
collection => collection.collectionId === meta.id
|
||||
)
|
||||
),
|
||||
[pinnedCollections, collectionMetas]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
return !addingFilter ? (
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<FilterIcon />}
|
||||
onClick={e => {
|
||||
// prevent default to avoid closing the menu
|
||||
e.preventDefault();
|
||||
setAddingFilter(true);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</MenuItem>
|
||||
{unpinnedCollectionMetas.length > 0 && <Divider />}
|
||||
{unpinnedCollectionMetas.map(meta => (
|
||||
<MenuItem
|
||||
key={meta.id}
|
||||
prefixIcon={<CollectionsIcon />}
|
||||
suffixIcon={<PlusIcon />}
|
||||
onClick={() => {
|
||||
onAddPinnedCollection(meta.id);
|
||||
}}
|
||||
>
|
||||
{meta.name ?? t['Untitled']()}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<AddFilterMenu onBack={() => setAddingFilter(false)} onAdd={onAddFilter} />
|
||||
);
|
||||
};
|
@ -1,20 +1,27 @@
|
||||
export { Collection } from './entities/collection';
|
||||
export type { CollectionMeta } from './services/collection';
|
||||
export { CollectionService } from './services/collection';
|
||||
export { PinnedCollectionService } from './services/pinned-collection';
|
||||
export type { CollectionInfo } from './stores/collection';
|
||||
export type { PinnedCollectionRecord } from './stores/pinned-collection';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { CollectionRulesService } from '../collection-rules';
|
||||
import { WorkspaceDBService } from '../db';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { Collection } from './entities/collection';
|
||||
import { CollectionService } from './services/collection';
|
||||
import { PinnedCollectionService } from './services/pinned-collection';
|
||||
import { CollectionStore } from './stores/collection';
|
||||
import { PinnedCollectionStore } from './stores/pinned-collection';
|
||||
|
||||
export function configureCollectionModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(CollectionService, [CollectionStore])
|
||||
.store(CollectionStore, [WorkspaceService])
|
||||
.entity(Collection, [CollectionStore, CollectionRulesService]);
|
||||
.entity(Collection, [CollectionStore, CollectionRulesService])
|
||||
.store(PinnedCollectionStore, [WorkspaceDBService])
|
||||
.service(PinnedCollectionService, [PinnedCollectionStore]);
|
||||
}
|
||||
|
@ -0,0 +1,71 @@
|
||||
import {
|
||||
generateFractionalIndexingKeyBetween,
|
||||
LiveData,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import type {
|
||||
PinnedCollectionRecord,
|
||||
PinnedCollectionStore,
|
||||
} from '../stores/pinned-collection';
|
||||
|
||||
export class PinnedCollectionService extends Service {
|
||||
constructor(private readonly pinnedCollectionStore: PinnedCollectionStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
pinnedCollections$ = LiveData.from<PinnedCollectionRecord[]>(
|
||||
this.pinnedCollectionStore.watchPinnedCollections(),
|
||||
[]
|
||||
);
|
||||
|
||||
sortedPinnedCollections$ = this.pinnedCollections$.map(records =>
|
||||
records.toSorted((a, b) => {
|
||||
return a.index > b.index ? 1 : -1;
|
||||
})
|
||||
);
|
||||
|
||||
addPinnedCollection(record: PinnedCollectionRecord) {
|
||||
this.pinnedCollectionStore.addPinnedCollection(record);
|
||||
}
|
||||
|
||||
removePinnedCollection(collectionId: string) {
|
||||
this.pinnedCollectionStore.removePinnedCollection(collectionId);
|
||||
}
|
||||
|
||||
indexAt(at: 'before' | 'after', targetId?: string) {
|
||||
if (!targetId) {
|
||||
if (at === 'before') {
|
||||
const first = this.sortedPinnedCollections$.value.at(0);
|
||||
return generateFractionalIndexingKeyBetween(null, first?.index || null);
|
||||
} else {
|
||||
const last = this.sortedPinnedCollections$.value.at(-1);
|
||||
return generateFractionalIndexingKeyBetween(last?.index || null, null);
|
||||
}
|
||||
} else {
|
||||
const sortedChildren = this.sortedPinnedCollections$.value;
|
||||
const targetIndex = sortedChildren.findIndex(
|
||||
node => node.collectionId === targetId
|
||||
);
|
||||
if (targetIndex === -1) {
|
||||
throw new Error('Target node not found');
|
||||
}
|
||||
const target = sortedChildren[targetIndex];
|
||||
const before: PinnedCollectionRecord | null =
|
||||
sortedChildren[targetIndex - 1] || null;
|
||||
const after: PinnedCollectionRecord | null =
|
||||
sortedChildren[targetIndex + 1] || null;
|
||||
if (at === 'before') {
|
||||
return generateFractionalIndexingKeyBetween(
|
||||
before?.index || null,
|
||||
target.index
|
||||
);
|
||||
} else {
|
||||
return generateFractionalIndexingKeyBetween(
|
||||
target.index,
|
||||
after?.index || null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { Store } from '@toeverything/infra';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import type { WorkspaceDBService } from '../../db';
|
||||
|
||||
export interface PinnedCollectionRecord {
|
||||
collectionId: string;
|
||||
index: string;
|
||||
}
|
||||
|
||||
export class PinnedCollectionStore extends Store {
|
||||
constructor(private readonly workspaceDBService: WorkspaceDBService) {
|
||||
super();
|
||||
}
|
||||
|
||||
watchPinnedCollections(): Observable<PinnedCollectionRecord[]> {
|
||||
return this.workspaceDBService.db.pinnedCollections.find$();
|
||||
}
|
||||
|
||||
addPinnedCollection(record: PinnedCollectionRecord) {
|
||||
this.workspaceDBService.db.pinnedCollections.create({
|
||||
collectionId: record.collectionId,
|
||||
index: record.index,
|
||||
});
|
||||
}
|
||||
|
||||
removePinnedCollection(collectionId: string) {
|
||||
this.workspaceDBService.db.pinnedCollections.delete(collectionId);
|
||||
}
|
||||
}
|
@ -42,6 +42,10 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
||||
isDeleted: f.boolean().optional(),
|
||||
// we will keep deleted properties in the database, for override legacy data
|
||||
},
|
||||
pinnedCollections: {
|
||||
collectionId: f.string().primaryKey(),
|
||||
index: f.string(),
|
||||
},
|
||||
} as const satisfies DBSchemaBuilder;
|
||||
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
"el-GR": 96,
|
||||
"en": 100,
|
||||
"es-AR": 96,
|
||||
"es-CL": 98,
|
||||
"es-CL": 97,
|
||||
"es": 96,
|
||||
"fa": 96,
|
||||
"fr": 96,
|
||||
|
@ -6968,6 +6968,10 @@ export function useAFFiNEI18N(): {
|
||||
* `Select checkbox`
|
||||
*/
|
||||
["com.affine.all-docs.quick-action.select"](): string;
|
||||
/**
|
||||
* `All`
|
||||
*/
|
||||
["com.affine.all-docs.pinned-collection.all"](): string;
|
||||
/**
|
||||
* `core`
|
||||
*/
|
||||
|
@ -1739,6 +1739,7 @@
|
||||
"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",
|
||||
"com.affine.all-docs.pinned-collection.all": "All",
|
||||
"core": "core",
|
||||
"dark": "Dark",
|
||||
"invited you to join": "invited you to join",
|
||||
|
Loading…
x
Reference in New Issue
Block a user