feat(shares): multi-select deletion on My Shares page (#3033)

The batch share-delete endpoint (DELETE /share {ids}) already existed
but the UI only exposed per-card deletion. The My Shares header now has
a Select mode: cards toggle via checkbox, with Select all and a
batch Delete action wired to the existing endpoint.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent e22277680e
commit 683810806a

@ -587,6 +587,23 @@ export function sendDeleteShare(id: string): ThunkResponse<void> {
}; };
} }
export function sendDeleteShares(ids: string[]): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/share",
{
method: "DELETE",
data: { ids },
},
{
...defaultOpts,
},
),
);
};
}
export function getShareInfo( export function getShareInfo(
id: string, id: string,
password?: string, password?: string,

@ -1,5 +1,6 @@
import { import {
Box, Box,
Checkbox,
Chip, Chip,
Grid, Grid,
ListItemIcon, ListItemIcon,
@ -42,6 +43,9 @@ export interface ShareCardProps {
onLoad?: () => void; onLoad?: () => void;
loading?: boolean; loading?: boolean;
onShareDeleted: (id: string) => void; onShareDeleted: (id: string) => void;
selecting?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
} }
interface ActionMenuProps extends MenuProps { interface ActionMenuProps extends MenuProps {
@ -127,7 +131,7 @@ const ActionMenu = ({ share, onShareDeleted, onClose, ...rest }: ActionMenuProps
); );
}; };
const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) => { const ShareCard = ({ share, onShareDeleted, onLoad, loading, selecting, selected, onToggleSelect }: ShareCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { ref, inView } = useInView({ const { ref, inView } = useInView({
@ -162,12 +166,21 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
expanded={false} expanded={false}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
if (selecting) {
return;
}
if (share?.owner?.id === user?.user.id) { if (share?.owner?.id === user?.user.id) {
popupState.open(e); popupState.open(e);
} }
}} }}
sx={{ p: 0, minHeight: 0, width: "100%", textAlign: "left" }} sx={{ p: 0, minHeight: 0, width: "100%", textAlign: "left" }}
{...(share?.owner?.id != user?.user.id {...(selecting && share?.owner?.id == user?.user.id
? {
onClick: () => {
share && onToggleSelect?.(share.id);
},
}
: share?.owner?.id != user?.user.id
? { ? {
onClick: () => { onClick: () => {
window.open(share?.url ?? "#", "_blank"); window.open(share?.url ?? "#", "_blank");
@ -187,7 +200,9 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
p: 1.5, p: 1.5,
}} }}
> >
{share ? ( {selecting ? (
<Checkbox size="small" checked={selected ?? false} disableRipple sx={{ p: 0, mt: 0.5 }} />
) : share ? (
<FileTypeIcon <FileTypeIcon
sx={{ sx={{
fontSize: 32, fontSize: 32,

@ -1,11 +1,14 @@
import * as React from "react"; import * as React from "react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Box, Container, FormControl, Grid, ListItemText, SelectChangeEvent } from "@mui/material"; import { Box, Button, Container, FormControl, Grid, ListItemText, SelectChangeEvent, Stack } from "@mui/material";
import { useSnackbar } from "notistack";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import PageHeader from "../PageHeader.tsx"; import PageHeader from "../PageHeader.tsx";
import { getShares } from "../../../api/api.ts"; import { getShares, sendDeleteShares } from "../../../api/api.ts";
import { useAppDispatch } from "../../../redux/hooks.ts"; import { useAppDispatch } from "../../../redux/hooks.ts";
import { confirmOperation } from "../../../redux/thunks/dialog.ts";
import Nothing from "../../Common/Nothing.tsx"; import Nothing from "../../Common/Nothing.tsx";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
import { setSelected } from "../../../redux/fileManagerSlice.ts"; import { setSelected } from "../../../redux/fileManagerSlice.ts";
import ShareCard from "./ShareCard.tsx"; import ShareCard from "./ShareCard.tsx";
import { Share } from "../../../api/explorer.ts"; import { Share } from "../../../api/explorer.ts";
@ -18,10 +21,13 @@ const defaultPageSize = 50;
const ShareList = () => { const ShareList = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const [nextPageToken, setNextPageToken] = useState<string | undefined>(""); const [nextPageToken, setNextPageToken] = useState<string | undefined>("");
const [shares, setShares] = useState<Share[]>([]); const [shares, setShares] = useState<Share[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [orderDirection, setOrderDirection] = useState("desc"); const [orderDirection, setOrderDirection] = useState("desc");
const [selecting, setSelecting] = useState(false);
const [selected, setSelectedIds] = useState<Set<string>>(new Set());
const loadNextPage = useCallback( const loadNextPage = useCallback(
(originShares: Share[], token?: string, direction?: string) => () => { (originShares: Share[], token?: string, direction?: string) => () => {
@ -62,6 +68,40 @@ const ShareList = () => {
[setShares], [setShares],
); );
const onToggleSelect = useCallback(
(id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
},
[setSelectedIds],
);
const exitSelecting = useCallback(() => {
setSelecting(false);
setSelectedIds(new Set());
}, []);
const deleteSelected = useCallback(() => {
dispatch(confirmOperation(t("fileManager.deleteShareWarning"))).then(() => {
dispatch(sendDeleteShares([...selected])).then(() => {
enqueueSnackbar({
message: t("application:share.shareCanceled"),
variant: "success",
action: DefaultCloseAction,
});
setShares((shares) => shares.filter((share) => !selected.has(share.id)));
exitSelecting();
});
});
}, [dispatch, selected, t, enqueueSnackbar, exitSelecting]);
const onSelectChange = useCallback( const onSelectChange = useCallback(
(e: SelectChangeEvent<unknown>) => { (e: SelectChangeEvent<unknown>) => {
setOrderDirection(e.target.value as string); setOrderDirection(e.target.value as string);
@ -75,6 +115,30 @@ const ShareList = () => {
<Container maxWidth="lg"> <Container maxWidth="lg">
<PageHeader <PageHeader
secondaryAction={ secondaryAction={
<Stack direction="row" spacing={1} alignItems="center">
{selecting && (
<>
<Button
size="small"
variant="text"
onClick={() => setSelectedIds(new Set(shares.map((s) => s.id)))}
>
{t("application:fileManager.selectAll")}
</Button>
<Button
size="small"
variant="text"
color="error"
disabled={selected.size == 0}
onClick={deleteSelected}
>
{t("fileManager.delete")} ({selected.size})
</Button>
</>
)}
<Button size="small" variant="text" onClick={selecting ? exitSelecting : () => setSelecting(true)}>
{selecting ? t("common:cancel") : t("common:select")}
</Button>
<FormControl variant="outlined"> <FormControl variant="outlined">
<DenseSelect variant="outlined" value={orderDirection} onChange={onSelectChange}> <DenseSelect variant="outlined" value={orderDirection} onChange={onSelectChange}>
<SquareMenuItem value={"desc"}> <SquareMenuItem value={"desc"}>
@ -97,6 +161,7 @@ const ShareList = () => {
</SquareMenuItem> </SquareMenuItem>
</DenseSelect> </DenseSelect>
</FormControl> </FormControl>
</Stack>
} }
onRefresh={() => refresh()} onRefresh={() => refresh()}
loading={loading} loading={loading}
@ -105,7 +170,14 @@ const ShareList = () => {
<Grid container spacing={1}> <Grid container spacing={1}>
{shares.map((share) => ( {shares.map((share) => (
<ShareCard share={share} key={share.id} onShareDeleted={onShareDeleted} /> <ShareCard
share={share}
key={share.id}
onShareDeleted={onShareDeleted}
selecting={selecting}
selected={selected.has(share.id)}
onToggleSelect={onToggleSelect}
/>
))} ))}
{nextPageToken != undefined && ( {nextPageToken != undefined && (
<> <>

Loading…
Cancel
Save