Multi-select actions

This commit is contained in:
Elio Struyf
2024-03-11 16:52:14 +01:00
parent 2a8d7b0ebe
commit 23b1efec55
16 changed files with 302 additions and 42 deletions

View File

@@ -229,6 +229,9 @@
"dashboard.media.folderCreation.hexo.create": "Create post asset folder",
"dashboard.media.folderCreation.folder.create": "Create new folder",
"dashboard.media.folderItem.contentDirectory": "Content directory",
"dashboard.media.folderItem.publicDirectory": "Public directory",
"dashboard.media.item.buttom.insert.image": "Insert image",
"dashboard.media.item.buttom.insert.snippet": "Insert snippet",

8
package-lock.json generated
View File

@@ -85,7 +85,7 @@
"react-quill": "^2.0.0",
"react-router-dom": "^6.8.0",
"react-sortable-hoc": "^2.0.0",
"recoil": "^0.4.1",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.8",
@@ -10193,9 +10193,9 @@
}
},
"node_modules/recoil": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/recoil/-/recoil-0.4.1.tgz",
"integrity": "sha512-vp6KPwlHOjJ4bJofmdDchmgI9ilMTCoUisK8/WYLl8dThH7e7KmtZttiLgvDb2Em99dUfTEsk8vT8L1nUMgqXQ==",
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz",
"integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==",
"dev": true,
"dependencies": {
"hamt_plus": "1.0.2"

View File

@@ -2816,7 +2816,7 @@
"react-quill": "^2.0.0",
"react-router-dom": "^6.8.0",
"react-sortable-hoc": "^2.0.0",
"recoil": "^0.4.1",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.8",

View File

@@ -0,0 +1,159 @@
import * as React from 'react';
import { NavigationType } from '../../models';
import { CommandLineIcon, PencilIcon, TrashIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useRecoilState, useRecoilValue } from 'recoil';
import { MultiSelectedItemsAtom, SelectedItemActionAtom, SelectedMediaFolderSelector, SettingsSelector } from '../../state';
import { ActionsBarItem } from './ActionsBarItem';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { Alert } from '../Modals/Alert';
import { messageHandler } from '@estruyf/vscode/dist/client';
import { DashboardMessage } from '../../DashboardMessage';
import { CustomScript, ScriptType } from '../../../models';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../../components/shadcn/Dropdown';
export interface IActionsBarProps {
view: NavigationType;
}
export const ActionsBar: React.FunctionComponent<IActionsBarProps> = ({
view
}: React.PropsWithChildren<IActionsBarProps>) => {
const [selectedFiles, setSelectedFiles] = useRecoilState(MultiSelectedItemsAtom);
const [, setSelectedItemAction] = useRecoilState(SelectedItemActionAtom);
const [showAlert, setShowAlert] = React.useState(false);
const selectedFolder = useRecoilValue(SelectedMediaFolderSelector);
const settings = useRecoilValue(SettingsSelector);
const onDeleteConfirm = React.useCallback(() => {
for (const file of selectedFiles) {
if (file) {
if (view === NavigationType.Contents) {
messageHandler.send(DashboardMessage.deleteFile, file);
} else if (view === NavigationType.Media) {
messageHandler.send(DashboardMessage.deleteMedia, {
file: file,
folder: selectedFolder
});
}
}
}
setSelectedFiles([]);
setShowAlert(false);
}, [selectedFiles]);
const runCustomScript = React.useCallback((script: CustomScript) => {
for (const file of selectedFiles) {
messageHandler.send(DashboardMessage.runCustomScript, {
script,
path: file
});
}
setSelectedFiles([]);
}, [selectedFiles]);
const customScriptActions = React.useMemo(() => {
if (!settings?.scripts) {
return null;
}
const { scripts } = settings;
if (view === NavigationType.Media) {
const mediaScripts = (scripts || [])
.filter((script) => script.type === ScriptType.MediaFile && !script.hidden);
if (mediaScripts.length > 0) {
return (
<DropdownMenu>
<DropdownMenuTrigger
className='flex items-center text-[var(--vscode-tab-inactiveForeground)] hover:text-[var(--vscode-tab-activeForeground)] disabled:opacity-50 disabled:hover:text-[var(--vscode-tab-inactiveForeground)]'
disabled={selectedFiles.length === 0}
>
<CommandLineIcon className="mr-2 h-4 w-4" aria-hidden={true} />
<span>Scripts</span>
<ChevronDownIcon className="ml-2 h-4 w-4" aria-hidden={true} />
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{
mediaScripts.map((script) => (
<DropdownMenuItem
key={script.id || script.title}
onClick={() => runCustomScript(script)}
>
<CommandLineIcon className="mr-2 h-4 w-4" aria-hidden={true} />
<span>{script.title}</span>
</DropdownMenuItem>
))
}
</DropdownMenuContent>
</DropdownMenu>
)
}
}
return null;
}, [view, settings?.scripts, selectedFiles]);
return (
<>
<div
className={`w-full flex items-center justify-between py-2 px-4 border-b bg-[var(--vscode-sideBar-background)] text-[var(--vscode-sideBar-foreground)] border-[var(--frontmatter-border)]`}
aria-label="Item actions"
>
{
view === NavigationType.Media && (
<div className='flex items-center space-x-6'>
<ActionsBarItem
disabled={selectedFiles.length === 0 || selectedFiles.length > 1}
onClick={() => setSelectedItemAction({
path: selectedFiles[0],
action: 'edit'
})}
>
<PencilIcon className="w-4 h-4 mr-2" aria-hidden="true" />
<span>{l10n.t(LocalizationKey.commonEdit)}</span>
</ActionsBarItem>
{customScriptActions}
<ActionsBarItem
className='hover:text-[var(--vscode-statusBarItem-errorBackground)]'
disabled={selectedFiles.length === 0}
onClick={() => setShowAlert(true)}
>
<TrashIcon className="w-4 h-4 mr-2" aria-hidden="true" />
<span>{l10n.t(LocalizationKey.commonDelete)}</span>
</ActionsBarItem>
</div>
)
}
{
selectedFiles.length > 0 && (
<button
type="button"
className='flex items-center hover:text-[var(--vscode-statusBarItem-warningBackground)]'
onClick={() => setSelectedFiles([])}
>
<XMarkIcon className="w-4 h-4 mr-1" aria-hidden="true" />
<span>{selectedFiles.length} selected</span>
</button>
)
}
</div>
{showAlert && (
<Alert
title={`${l10n.t(LocalizationKey.commonDelete)}`}
description={`Are you sure you want to delete the selected files?`}
okBtnText={l10n.t(LocalizationKey.commonDelete)}
cancelBtnText={l10n.t(LocalizationKey.commonCancel)}
dismiss={() => setShowAlert(false)}
trigger={onDeleteConfirm}
/>
)}
</>
);
};

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { cn } from '../../../utils/cn';
export interface IActionsBarItemProps {
className?: string;
disabled?: boolean;
onClick?: () => void;
}
export const ActionsBarItem: React.FunctionComponent<IActionsBarItemProps> = ({
children,
className,
disabled,
onClick
}: React.PropsWithChildren<IActionsBarItemProps>) => {
return (
<button
type="button"
className={cn(`flex items-center text-[var(--vscode-tab-inactiveForeground)] hover:text-[var(--vscode-tab-activeForeground)] disabled:opacity-50 disabled:hover:text-[var(--vscode-tab-inactiveForeground)]`, className)}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};

View File

@@ -4,24 +4,25 @@ import * as React from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { HOME_PAGE_NAVIGATION_ID } from '../../../constants';
import { parseWinPath } from '../../../helpers/parseWinPath';
import { SearchAtom, SelectedMediaFolderAtom, SettingsAtom } from '../../state';
import { SearchAtom, SettingsAtom } from '../../state';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import useMediaFolder from '../../hooks/useMediaFolder';
export interface IBreadcrumbProps { }
export const Breadcrumb: React.FunctionComponent<IBreadcrumbProps> = (
_: React.PropsWithChildren<IBreadcrumbProps>
) => {
const [selectedFolder, setSelectedFolder] = useRecoilState(SelectedMediaFolderAtom);
const { selectedFolder, updateFolder } = useMediaFolder();
const [, setSearchValue] = useRecoilState(SearchAtom);
const [folders, setFolders] = React.useState<string[]>([]);
const settings = useRecoilValue(SettingsAtom);
const updateFolder = (folder: string) => {
const updateMediaFolder = React.useCallback((folder: string) => {
setSearchValue('');
setSelectedFolder(folder);
};
updateFolder(folder);
}, [updateFolder, setSearchValue]);
React.useEffect(() => {
if (!settings) {
@@ -79,11 +80,11 @@ export const Breadcrumb: React.FunctionComponent<IBreadcrumbProps> = (
}, [selectedFolder, settings]);
return (
<ol role="list" className="flex space-x-4 px-5 flex-1">
<ol role="list" className="flex space-x-2 px-4 flex-1">
<li className="flex">
<div className="flex items-center">
<button
onClick={() => setSelectedFolder(HOME_PAGE_NAVIGATION_ID)}
onClick={() => updateMediaFolder(HOME_PAGE_NAVIGATION_ID)}
className={`text-[var(--vscode-tab-inactiveForeground)] hover:text-[var(--vscode-tab-activeForeground)]`}
>
<HomeIcon className="flex-shrink-0 h-5 w-5" aria-hidden="true" />
@@ -106,8 +107,8 @@ export const Breadcrumb: React.FunctionComponent<IBreadcrumbProps> = (
</svg>
<button
onClick={() => updateFolder(folder)}
className={`ml-4 text-sm font-medium text-[var(--vscode-tab-inactiveForeground)] hover:text-[var(--vscode-tab-activeForeground)]`}
onClick={() => updateMediaFolder(folder)}
className={`ml-2 text-sm font-medium text-[var(--vscode-tab-inactiveForeground)] hover:text-[var(--vscode-tab-activeForeground)]`}
>
{basename(folder)}
</button>

View File

@@ -6,7 +6,7 @@ import { DashboardMessage } from '../../DashboardMessage';
import { Grouping } from '.';
import { ViewSwitch } from './ViewSwitch';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import { GroupingSelector, SortingAtom } from '../../state';
import { GroupingSelector, MultiSelectedItemsAtom, SortingAtom } from '../../state';
import { Messenger } from '@estruyf/vscode/dist/client';
import { ClearFilters } from './ClearFilters';
import { MediaHeaderTop } from '../Media/MediaHeaderTop';
@@ -18,8 +18,7 @@ import { ArrowTopRightOnSquareIcon, BoltIcon, PlusIcon } from '@heroicons/react/
import { HeartIcon } from '@heroicons/react/24/solid';
import { useLocation, useNavigate } from 'react-router-dom';
import { routePaths } from '../..';
import { useEffect, useMemo } from 'react';
import { SyncButton } from './SyncButton';
import { useMemo } from 'react';
import { Pagination } from './Pagination';
import { GroupOption } from '../../constants/GroupOption';
import usePagination from '../../hooks/usePagination';
@@ -32,6 +31,7 @@ import { SettingsLink } from '../SettingsView/SettingsLink';
import { Link } from '../Common/Link';
import { SPONSOR_LINK } from '../../../constants';
import { Filters } from './Filters';
import { ActionsBar } from './ActionsBar';
export interface IHeaderProps {
header?: React.ReactNode;
@@ -51,6 +51,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({
}: React.PropsWithChildren<IHeaderProps>) => {
const grouping = useRecoilValue(GroupingSelector);
const resetSorting = useResetRecoilState(SortingAtom);
const resetSelectedItems = useResetRecoilState(MultiSelectedItemsAtom);
const location = useLocation();
const navigate = useNavigate();
const { pageSetNr } = usePagination(settings?.dashboardState.contents.pagination);
@@ -70,6 +71,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({
const updateView = (view: NavigationType) => {
navigate(routePaths[view]);
resetSorting();
resetSelectedItems();
};
const runBulkScript = (script: CustomScript) => {
@@ -216,6 +218,8 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({
<MediaHeaderTop />
<MediaHeaderBottom />
<ActionsBar view={NavigationType.Media} />
</>
)}

View File

@@ -5,7 +5,6 @@ import { DashboardMessage } from '../../DashboardMessage';
import {
AllContentFoldersAtom,
AllStaticFoldersAtom,
SelectedMediaFolderAtom,
SettingsSelector,
ViewDataSelector
} from '../../state';
@@ -18,13 +17,14 @@ import { extname } from 'path';
import { parseWinPath } from '../../../helpers/parseWinPath';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import useMediaFolder from '../../hooks/useMediaFolder';
export interface IFolderCreationProps { }
export const FolderCreation: React.FunctionComponent<IFolderCreationProps> = (
props: React.PropsWithChildren<IFolderCreationProps>
_: React.PropsWithChildren<IFolderCreationProps>
) => {
const selectedFolder = useRecoilValue(SelectedMediaFolderAtom);
const { selectedFolder } = useMediaFolder();
const settings = useRecoilValue(SettingsSelector);
const allStaticFolders = useRecoilValue(AllStaticFoldersAtom);
const allContentFolders = useRecoilValue(AllContentFoldersAtom);

View File

@@ -1,8 +1,9 @@
import { FolderIcon } from '@heroicons/react/24/solid';
import { basename, join } from 'path';
import * as React from 'react';
import { useRecoilState } from 'recoil';
import { SelectedMediaFolderAtom } from '../../state';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import useMediaFolder from '../../hooks/useMediaFolder';
export interface IFolderItemProps {
folder: string;
@@ -15,7 +16,7 @@ export const FolderItem: React.FunctionComponent<IFolderItemProps> = ({
wsFolder,
staticFolder
}: React.PropsWithChildren<IFolderItemProps>) => {
const [, setSelectedFolder] = useRecoilState(SelectedMediaFolderAtom);
const { updateFolder } = useMediaFolder();
const relFolderPath = wsFolder ? folder.replace(wsFolder, '') : folder;
@@ -29,9 +30,9 @@ export const FolderItem: React.FunctionComponent<IFolderItemProps> = ({
className={`group relative hover:bg-[var(--vscode-list-hoverBackground)] text-[var(--vscode-editor-foreground)] hover:text-[var(--vscode-list-activeSelectionForeground)]`}
>
<button
title={isContentFolder ? 'Content directory folder' : 'Public directory folder'}
title={isContentFolder ? l10n.t(LocalizationKey.dashboardMediaFolderItemContentDirectory) : l10n.t(LocalizationKey.dashboardMediaFolderItemPublicDirectory)}
className={`p-4 w-full flex flex-row items-center h-full`}
onClick={() => setSelectedFolder(folder)}
onClick={() => updateFolder(folder)}
>
<div className="relative mr-4">
<FolderIcon className={`h-12 w-12`} />

View File

@@ -17,6 +17,8 @@ import { MediaInfo } from '../../../models/MediaPaths';
import { DashboardMessage } from '../../DashboardMessage';
import {
LightboxAtom,
MultiSelectedItemsAtom,
SelectedItemActionAtom,
SelectedMediaFolderSelector,
SettingsSelector,
ViewDataSelector
@@ -40,6 +42,8 @@ export const Item: React.FunctionComponent<IItemProps> = ({
media,
}: React.PropsWithChildren<IItemProps>) => {
const [, setLightbox] = useRecoilState(LightboxAtom);
const [selectedFiles, setSelectedFiles] = useRecoilState(MultiSelectedItemsAtom);
const [selectedItemAction, setSelectedItemAction] = useRecoilState(SelectedItemActionAtom);
const [showAlert, setShowAlert] = useState(false);
const [showForm, setShowForm] = useState(false);
const [showSnippetSelection, setShowSnippetSelection] = useState(false);
@@ -191,14 +195,14 @@ export const Item: React.FunctionComponent<IItemProps> = ({
});
};
const getDimensions = () => {
const dimensions = useMemo(() => {
if (media.dimensions) {
return `${media.dimensions.width} x ${media.dimensions.height}`;
}
return '';
};
}, [media]);
const getSize = () => {
const size = useMemo(() => {
if (media?.size) {
const size = media.size / (1024 * 1024);
if (size > 1) {
@@ -209,23 +213,21 @@ export const Item: React.FunctionComponent<IItemProps> = ({
}
return '';
};
}, [media]);
const getMediaDetails = () => {
const mediaDetails = useMemo(() => {
let sizeDetails = [];
const dimensions = getDimensions();
if (dimensions) {
sizeDetails.push(dimensions);
}
const size = getSize();
if (size) {
sizeDetails.push(size);
}
return sizeDetails.join(' - ');
};
}, [media, dimensions, size]);
const openLightbox = useCallback(() => {
if (isImageFile) {
@@ -314,12 +316,29 @@ export const Item: React.FunctionComponent<IItemProps> = ({
return null;
}, [media]);
const onMultiSelect = useCallback(() => {
if (selectedFiles.includes(media.fsPath)) {
setSelectedFiles(selectedFiles.filter((file) => file !== media.fsPath));
} else {
setSelectedFiles([...selectedFiles, media.fsPath]);
}
}, [selectedFiles]);
const clearFormData = () => {
setShowSnippetFormDialog(false);
setSnippet(undefined);
setMediaData(undefined);
};
useEffect(() => {
if (selectedItemAction && selectedItemAction.path === media.fsPath) {
if (selectedItemAction.action === 'edit') {
updateMetadata();
setSelectedItemAction(undefined);
}
}
}, [media, selectedItemAction])
useEffect(() => {
const name = basename(parseWinPath(media.fsPath) || '');
if (name !== filename) {
@@ -351,11 +370,13 @@ export const Item: React.FunctionComponent<IItemProps> = ({
{renderMedia}
</div>
<div className='hidden group-hover:block absolute top-2 left-2 white'>
<div className={`${selectedFiles.includes(media.fsPath) ? 'block' : 'hidden'} group-hover:block absolute top-2 left-2 white`}>
<VSCodeCheckbox
onClick={(e: any) => {
onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
}} />
onMultiSelect();
}}
checked={selectedFiles.includes(media.fsPath)} />
</div>
{hasViewData && (
@@ -439,7 +460,7 @@ export const Item: React.FunctionComponent<IItemProps> = ({
{l10n.t(LocalizationKey.dashboardMediaCommonSize)}:
</b>
<span className={`block mt-1 text-xs text-[var(--vscode-foreground)]`}>
{getMediaDetails()}
{mediaDetails}
</span>
</p>
)}
@@ -471,8 +492,8 @@ export const Item: React.FunctionComponent<IItemProps> = ({
{showDetails && (
<DetailsSlideOver
imgSrc={media.vsPath || ''}
size={getSize()}
dimensions={getDimensions()}
size={size}
dimensions={dimensions}
folder={getFolder()}
media={media}
showForm={showForm}

View File

@@ -12,12 +12,12 @@ import {
MediaTotalAtom,
PageAtom,
SearchAtom,
SelectedMediaFolderAtom,
SettingsAtom
} from '../state';
import Fuse from 'fuse.js';
import usePagination from './usePagination';
import { usePrevious } from '../../panelWebView/hooks/usePrevious';
import useMediaFolder from './useMediaFolder';
const fuseOptions: Fuse.IFuseOptions<MediaInfo> = {
keys: [
@@ -35,7 +35,7 @@ export default function useMedia() {
// const page = useRecoilValue(PageAtom);
const [page, setPage] = useRecoilState(PageAtom);
const [searchedMedia, setSearchedMedia] = useState<MediaInfo[]>([]);
const [, setSelectedFolder] = useRecoilState(SelectedMediaFolderAtom);
const { updateFolder } = useMediaFolder();
const [, setTotal] = useRecoilState(MediaTotalAtom);
const [, setFolders] = useRecoilState(MediaFoldersAtom);
const [, setAllContentFolders] = useRecoilState(AllContentFoldersAtom);
@@ -79,7 +79,7 @@ export default function useMedia() {
setMedia(payload.media);
setTotal(payload.total);
setFolders(payload.folders);
setSelectedFolder(payload.selectedFolder);
updateFolder(payload.selectedFolder);
if (search) {
searchMedia(search, payload.media);
} else {

View File

@@ -0,0 +1,17 @@
import { useRecoilState } from 'recoil';
import { MultiSelectedItemsAtom, SelectedMediaFolderAtom } from '../state';
export default function useMediaFolder() {
const [selectedFolder, setSelectedFolder] = useRecoilState(SelectedMediaFolderAtom);
const [, setSelectedFiles] = useRecoilState(MultiSelectedItemsAtom);
const updateFolder = (folder: string) => {
setSelectedFolder(folder);
setSelectedFiles([]);
};
return {
selectedFolder,
updateFolder
};
}

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const MultiSelectedItemsAtom = atom<string[]>({
key: 'MultiSelectedItemsAtom',
default: []
});

View File

@@ -0,0 +1,12 @@
import { atom } from 'recoil';
export const SelectedItemActionAtom = atom<
| {
path: string;
action: 'edit';
}
| undefined
>({
key: 'SelectedItemActionAtom',
default: undefined
});

View File

@@ -14,10 +14,12 @@ export * from './LocalesAtom';
export * from './MediaFoldersAtom';
export * from './MediaTotalAtom';
export * from './ModeAtom';
export * from './MultiSelectedItemsAtom';
export * from './PageAtom';
export * from './PinnedItems';
export * from './SearchAtom';
export * from './SearchReadyAtom';
export * from './SelectedItemActionAtom';
export * from './SelectedMediaFolderAtom';
export * from './SettingsAtom';
export * from './SortingAtom';

View File

@@ -739,6 +739,14 @@ export enum LocalizationKey {
* Create new folder
*/
dashboardMediaFolderCreationFolderCreate = 'dashboard.media.folderCreation.folder.create',
/**
* Content directory
*/
dashboardMediaFolderItemContentDirectory = 'dashboard.media.folderItem.contentDirectory',
/**
* Public directory
*/
dashboardMediaFolderItemPublicDirectory = 'dashboard.media.folderItem.publicDirectory',
/**
* Insert image
*/