mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 20:57:08 +08:00
feat(core): unused blob management in settings (#9795)
fix AF-2144, PD-2064, PD-2065, PD-2066
This commit is contained in:
@@ -74,9 +74,11 @@ export const sidebarSelectSubItem = style({
|
||||
export const sidebarSelectItemIcon = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
fontSize: '16px',
|
||||
marginRight: '10px',
|
||||
flexShrink: 0,
|
||||
color: cssVarV2('icon/primary'),
|
||||
display: 'inline-flex',
|
||||
});
|
||||
|
||||
export const sidebarSelectItemName = style({
|
||||
|
||||
@@ -43,7 +43,7 @@ export const WorkspaceSetting = ({
|
||||
case 'workspace:billing':
|
||||
return <WorkspaceSettingBilling />;
|
||||
case 'workspace:storage':
|
||||
return <WorkspaceSettingStorage />;
|
||||
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Loading,
|
||||
templateToString,
|
||||
useConfirmModal,
|
||||
useDisposable,
|
||||
} from '@affine/component';
|
||||
import { Pagination } from '@affine/component/member-components';
|
||||
import { BlobManagementService } from '@affine/core/modules/blob-management/services';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { ListedBlobRecord } from '@affine/nbstore';
|
||||
import { getAttachmentFileIcon } from '@blocksuite/affine/blocks';
|
||||
import { DeleteIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import bytes from 'bytes';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import * as styles from './style.css';
|
||||
|
||||
const Empty = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
{t['com.affine.settings.workspace.storage.unused-blobs.empty']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useBlob = (blobRecord: ListedBlobRecord) => {
|
||||
const unusedBlobsEntity = useService(BlobManagementService).unusedBlobs;
|
||||
return useDisposable(
|
||||
(abortSignal?: AbortSignal) =>
|
||||
unusedBlobsEntity.hydrateBlob(blobRecord, abortSignal),
|
||||
[blobRecord]
|
||||
);
|
||||
};
|
||||
|
||||
const BlobPreview = ({ blobRecord }: { blobRecord: ListedBlobRecord }) => {
|
||||
const { data, loading, error } = useBlob(blobRecord);
|
||||
|
||||
const element = useMemo(() => {
|
||||
if (loading) return <Loading size={24} />;
|
||||
if (!data?.url || !data.type) return null;
|
||||
|
||||
const { url, type, mime } = data;
|
||||
|
||||
const icon = templateToString(getAttachmentFileIcon(type));
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={styles.unknownBlobIcon}
|
||||
dangerouslySetInnerHTML={{ __html: icon }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mime?.startsWith('image/')) {
|
||||
return (
|
||||
<img
|
||||
className={styles.blobImagePreview}
|
||||
src={url}
|
||||
alt={blobRecord.key}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className={styles.unknownBlobIcon}
|
||||
dangerouslySetInnerHTML={{ __html: icon }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [loading, data, error, blobRecord.key]);
|
||||
|
||||
return (
|
||||
<div className={styles.blobPreviewContainer}>
|
||||
<div className={styles.blobPreview}>{element}</div>
|
||||
<div className={styles.blobPreviewFooter}>
|
||||
<div className={styles.blobPreviewName}>{blobRecord.key}</div>
|
||||
<div className={styles.blobPreviewInfo}>
|
||||
{data?.type} · {bytes(blobRecord.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BlobCard = ({
|
||||
blobRecord,
|
||||
onClick,
|
||||
selected,
|
||||
}: {
|
||||
blobRecord: ListedBlobRecord;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="blob-preview-card"
|
||||
className={styles.blobCard}
|
||||
data-selected={selected}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Checkbox className={styles.blobGridItemCheckbox} checked={selected} />
|
||||
<BlobPreview blobRecord={blobRecord} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 9;
|
||||
|
||||
export const BlobManagementPanel = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const unusedBlobsEntity = useService(BlobManagementService).unusedBlobs;
|
||||
const originalUnusedBlobs = useLiveData(unusedBlobsEntity.unusedBlobs$);
|
||||
const isLoading = useLiveData(unusedBlobsEntity.isLoading$);
|
||||
const [pageNum, setPageNum] = useState(0);
|
||||
const [skip, setSkip] = useState(0);
|
||||
|
||||
const [unusedBlobs, setUnusedBlobs] = useState<ListedBlobRecord[]>([]);
|
||||
const unusedBlobsPage = useMemo(() => {
|
||||
return unusedBlobs.slice(skip, skip + PAGE_SIZE);
|
||||
}, [unusedBlobs, skip]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnusedBlobs(originalUnusedBlobs);
|
||||
}, [originalUnusedBlobs]);
|
||||
|
||||
useEffect(() => {
|
||||
unusedBlobsEntity.revalidate();
|
||||
}, [unusedBlobsEntity]);
|
||||
|
||||
const [selectedBlobs, setSelectedBlobs] = useState<ListedBlobRecord[]>([]);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleSelectBlob = useCallback((blob: ListedBlobRecord) => {
|
||||
setSelectedBlobs(prev => {
|
||||
if (prev.includes(blob)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, blob];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleUnselectBlob = useCallback((blob: ListedBlobRecord) => {
|
||||
setSelectedBlobs(prev => prev.filter(b => b.key !== blob.key));
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
unusedBlobsPage.forEach(blob => handleSelectBlob(blob));
|
||||
}, [unusedBlobsPage, handleSelectBlob]);
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const handleDeleteSelectedBlobs = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentSelectedBlobs = selectedBlobs;
|
||||
openConfirmModal({
|
||||
title:
|
||||
t[
|
||||
'com.affine.settings.workspace.storage.unused-blobs.delete.title'
|
||||
](),
|
||||
children:
|
||||
t[
|
||||
'com.affine.settings.workspace.storage.unused-blobs.delete.warning'
|
||||
](),
|
||||
onConfirm: async () => {
|
||||
setDeleting(true);
|
||||
for (const blob of currentSelectedBlobs) {
|
||||
await unusedBlobsEntity.deleteBlob(blob.key, true);
|
||||
handleUnselectBlob(blob);
|
||||
setUnusedBlobs(prev => prev.filter(b => b.key !== blob.key));
|
||||
}
|
||||
setDeleting(false);
|
||||
},
|
||||
confirmText: t['Delete'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
});
|
||||
},
|
||||
[selectedBlobs, openConfirmModal, t, unusedBlobsEntity, handleUnselectBlob]
|
||||
);
|
||||
|
||||
const blobPreviewGridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (blobPreviewGridRef.current) {
|
||||
const unselectBlobs = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
!blobPreviewGridRef.current?.contains(target) &&
|
||||
!target.closest('modal-transition-container')
|
||||
) {
|
||||
setSelectedBlobs([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', unselectBlobs);
|
||||
return () => {
|
||||
document.removeEventListener('click', unselectBlobs);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [unusedBlobs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedBlobs.length > 0 ? (
|
||||
<div className={styles.blobManagementControls}>
|
||||
<div className={styles.blobManagementName}>
|
||||
{`${selectedBlobs.length} ${t['com.affine.settings.workspace.storage.unused-blobs.selected']()}`}
|
||||
</div>
|
||||
<div className={styles.spacer} />
|
||||
<Button onClick={handleSelectAll} variant="primary">
|
||||
{t['com.affine.keyboardShortcuts.selectAll']()}
|
||||
</Button>
|
||||
<Button
|
||||
loading={deleting}
|
||||
onClick={handleDeleteSelectedBlobs}
|
||||
prefix={<DeleteIcon />}
|
||||
disabled={deleting}
|
||||
>
|
||||
{t['Delete']()}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.blobManagementNameInactive}>
|
||||
{`${t['com.affine.settings.workspace.storage.unused-blobs']()} (${unusedBlobs.length})`}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.blobManagementContainer}>
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Loading size={32} />
|
||||
</div>
|
||||
) : unusedBlobs.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.blobPreviewGrid} ref={blobPreviewGridRef}>
|
||||
{unusedBlobs.slice(skip, skip + PAGE_SIZE).map(blob => {
|
||||
const selected = selectedBlobs.includes(blob);
|
||||
return (
|
||||
<BlobCard
|
||||
key={blob.key}
|
||||
blobRecord={blob}
|
||||
onClick={() =>
|
||||
selected
|
||||
? handleUnselectBlob(blob)
|
||||
: handleSelectBlob(blob)
|
||||
}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
pageNum={pageNum}
|
||||
totalCount={unusedBlobs.length}
|
||||
countPerPage={PAGE_SIZE}
|
||||
onPageChange={(_, pageNum) => {
|
||||
setPageNum(pageNum);
|
||||
setSkip(pageNum * PAGE_SIZE);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+19
-5
@@ -6,10 +6,16 @@ import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
|
||||
import { EnableCloudPanel } from '../preference/enable-cloud';
|
||||
import { BlobManagementPanel } from './blob-management';
|
||||
import { DesktopExportPanel } from './export';
|
||||
import { WorkspaceQuotaPanel } from './workspace-quota';
|
||||
|
||||
export const WorkspaceSettingStorage = () => {
|
||||
export const WorkspaceSettingStorage = ({
|
||||
onCloseSetting,
|
||||
}: {
|
||||
onCloseSetting: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
return (
|
||||
@@ -18,10 +24,18 @@ export const WorkspaceSettingStorage = () => {
|
||||
title={t['Storage']()}
|
||||
subtitle={t['com.affine.settings.workspace.storage.subtitle']()}
|
||||
/>
|
||||
{workspace.flavour !== 'local' && (
|
||||
<SettingWrapper>
|
||||
<WorkspaceQuotaPanel />
|
||||
</SettingWrapper>
|
||||
{workspace.flavour === 'local' ? (
|
||||
<EnableCloudPanel onCloseSetting={onCloseSetting} />
|
||||
) : (
|
||||
<>
|
||||
<SettingWrapper>
|
||||
<WorkspaceQuotaPanel />
|
||||
</SettingWrapper>
|
||||
|
||||
<SettingWrapper>
|
||||
<BlobManagementPanel />
|
||||
</SettingWrapper>
|
||||
</>
|
||||
)}
|
||||
{BUILD_CONFIG.isElectron && (
|
||||
<SettingWrapper>
|
||||
|
||||
+133
@@ -28,3 +28,136 @@ globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, {
|
||||
export const storageProgressBar = style({
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
// blob management
|
||||
|
||||
// when no blob is selected
|
||||
export const blobManagementControls = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const blobManagementName = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
height: '28px',
|
||||
});
|
||||
|
||||
export const blobManagementNameInactive = style([
|
||||
blobManagementName,
|
||||
{
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const blobManagementContainer = style({
|
||||
marginTop: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
});
|
||||
|
||||
export const blobPreviewGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(30%, 1fr))',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const blobCard = style({
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const loadingContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '320px',
|
||||
});
|
||||
|
||||
export const empty = style({
|
||||
padding: '8px 16px',
|
||||
});
|
||||
|
||||
export const blobPreviewContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const blobPreview = style({
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
aspectRatio: '1',
|
||||
borderRadius: '4px',
|
||||
padding: 6,
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
border: `2px solid transparent`,
|
||||
selectors: {
|
||||
[`${blobCard}[data-selected="true"] &`]: {
|
||||
borderColor: cssVarV2('button/primary'),
|
||||
},
|
||||
[`${blobCard}:hover &`]: {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const blobGridItemCheckbox = style({
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
fontSize: 16,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${blobCard}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
[`${blobCard}[data-selected="true"] &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const blobImagePreview = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
export const unknownBlobIcon = style({});
|
||||
|
||||
export const blobPreviewFooter = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const blobPreviewName = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
});
|
||||
|
||||
export const blobPreviewInfo = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ export const StorageProgress = () => {
|
||||
if (loadError) {
|
||||
return <ErrorMessage>Load error</ErrorMessage>;
|
||||
}
|
||||
return <Skeleton height={42} />;
|
||||
return <Skeleton height={26} />;
|
||||
}
|
||||
|
||||
if (!isTeam) {
|
||||
|
||||
Reference in New Issue
Block a user