diff --git a/packages/danmaku-anywhere/src/background/rpc/RpcManager.ts b/packages/danmaku-anywhere/src/background/rpc/RpcManager.ts index ef1f0336..7a04a68d 100644 --- a/packages/danmaku-anywhere/src/background/rpc/RpcManager.ts +++ b/packages/danmaku-anywhere/src/background/rpc/RpcManager.ts @@ -166,6 +166,9 @@ export class RpcManager { seasonMapDelete: async (data) => { return this.titleMappingService.remove(data.key) }, + seasonMapDeleteMany: async (data) => { + return this.titleMappingService.removeMany(data.keys) + }, seasonMapGetAll: async () => { const seasonMaps = await this.titleMappingService.getAll() diff --git a/packages/danmaku-anywhere/src/background/services/persistence/TitleMappingService.ts b/packages/danmaku-anywhere/src/background/services/persistence/TitleMappingService.ts index 303a79be..266f870e 100644 --- a/packages/danmaku-anywhere/src/background/services/persistence/TitleMappingService.ts +++ b/packages/danmaku-anywhere/src/background/services/persistence/TitleMappingService.ts @@ -39,6 +39,11 @@ export class TitleMappingService { await this.db.seasonMap.where({ key }).delete() } + async removeMany(keys: string[]) { + this.logger.debug('Removing title mappings:', keys) + await this.db.seasonMap.where('key').anyOf(keys).delete() + } + async get(key: string) { const snapshot = await this.db.seasonMap.get({ key }) return snapshot ? SeasonMap.fromSnapshot(snapshot) : undefined diff --git a/packages/danmaku-anywhere/src/common/components/DanmakuSelector/components/MountPageToolbar.tsx b/packages/danmaku-anywhere/src/common/components/DanmakuSelector/components/MountPageToolbar.tsx index aee9791b..c1551c60 100644 --- a/packages/danmaku-anywhere/src/common/components/DanmakuSelector/components/MountPageToolbar.tsx +++ b/packages/danmaku-anywhere/src/common/components/DanmakuSelector/components/MountPageToolbar.tsx @@ -1,17 +1,10 @@ import type { DanmakuSourceType } from '@danmaku-anywhere/danmaku-converter' -import { CheckBox, CheckBoxOutlined } from '@mui/icons-material' -import { - Button, - Checkbox, - Chip, - Collapse, - Stack, - Typography, -} from '@mui/material' +import { Button, Checkbox, Collapse } from '@mui/material' import { useTranslation } from 'react-i18next' import { FilterButton } from '@/common/components/FilterButton' import { TabToolbar } from '@/common/components/layout/TabToolbar' import { DrilldownMenu } from '@/common/components/Menu/DrilldownMenu' +import { MultiselectChip } from '@/common/components/MultiselectChip' import { TypeSelector } from '@/common/components/TypeSelector' import type { DAMenuItemConfig } from '../../Menu/DAMenuItemConfig' @@ -82,23 +75,7 @@ export const MountPageToolbar = ({ selectedTypes={selectedTypes as DanmakuSourceType[]} setSelectedType={(types) => onSelectedTypesChange(types)} /> - - {multiselect ? ( - - ) : ( - - )} - - {t('common.multiselect', 'Multiselect')} - - - } - onClick={onToggleMultiselect} - color="primary" - /> + {onUnmount && ( diff --git a/packages/danmaku-anywhere/src/common/components/DraggableList.tsx b/packages/danmaku-anywhere/src/common/components/DraggableList.tsx index 9cfab3b0..1049f43c 100644 --- a/packages/danmaku-anywhere/src/common/components/DraggableList.tsx +++ b/packages/danmaku-anywhere/src/common/components/DraggableList.tsx @@ -19,6 +19,7 @@ import { import { CSS } from '@dnd-kit/utilities' import { DragIndicator } from '@mui/icons-material' import { + Checkbox, List, ListItem, ListItemButton, @@ -28,7 +29,7 @@ import { styled, } from '@mui/material' import type { ReactNode } from 'react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { NothingHere } from '@/common/components/NothingHere' import { ScrollBox } from './layout/ScrollBox' @@ -64,7 +65,10 @@ interface SortableItemProps { item: T clickable?: boolean disableReorder?: boolean + multiselect?: boolean + selected?: boolean onEdit?: (item: T) => void + onToggleSelect?: (item: T) => void renderPrimary: (item: T) => ReactNode renderSecondary?: (item: T) => ReactNode renderSecondaryAction?: (item: T) => ReactNode @@ -74,7 +78,10 @@ function SortableItem({ item, clickable = true, disableReorder, + multiselect, + selected, onEdit, + onToggleSelect, renderPrimary, renderSecondary, renderSecondaryAction, @@ -89,7 +96,11 @@ function SortableItem({ } = useSortable({ id: item.id }) function handleClick() { - onEdit?.(item) + if (multiselect) { + onToggleSelect?.(item) + } else { + onEdit?.(item) + } } const style = { @@ -103,9 +114,22 @@ function SortableItem({ secondary: renderSecondary?.(item), } + const isClickable = multiselect || clickable + const listItemInner = ( <> - {!disableReorder ? ( + {multiselect ? ( + + + + ) : !disableReorder ? ( @@ -120,12 +144,16 @@ function SortableItem({ style={style} key={item.id} secondaryAction={ - renderSecondaryAction ? renderSecondaryAction(item) : null + multiselect + ? null + : renderSecondaryAction + ? renderSecondaryAction(item) + : null } - disablePadding={clickable} + disablePadding={isClickable} {...attributes} > - {clickable ? ( + {isClickable ? ( {listItemInner} ) : ( listItemInner @@ -168,6 +196,9 @@ function DragOverlayItem({ export interface DraggableListProps { items: T[] clickable?: boolean | ((item: T) => boolean) + multiselect?: boolean + selectedIds?: string[] + onSelectionChange?: (ids: string[]) => void onEdit?: (item: T) => void onReorder?: (sourceIndex: number, destinationIndex: number) => void renderPrimary: (item: T) => ReactNode @@ -181,6 +212,9 @@ export interface DraggableListProps { export function DraggableList({ items, clickable, + multiselect, + selectedIds, + onSelectionChange, overlayPortal, disableReorder, onEdit, @@ -241,6 +275,20 @@ export function DraggableList({ setActiveId(null) } + const selectedIdSet = useMemo(() => new Set(selectedIds ?? []), [selectedIds]) + + function handleToggleSelect(item: T) { + if (!onSelectionChange) { + return + } + const currentIds = selectedIds ?? [] + if (selectedIdSet.has(item.id)) { + onSelectionChange(currentIds.filter((id) => id !== item.id)) + } else { + onSelectionChange([...currentIds, item.id]) + } + } + function getIsClickable(item: T) { return typeof clickable === 'function' ? clickable(item) : clickable } @@ -290,8 +338,11 @@ export function DraggableList({ key={item.id} clickable={getIsClickable(item)} item={item} - disableReorder={disableReorder} + disableReorder={multiselect || disableReorder} + multiselect={multiselect} + selected={selectedIdSet.has(item.id)} onEdit={onEdit} + onToggleSelect={handleToggleSelect} renderPrimary={renderPrimary} renderSecondary={renderSecondary} renderSecondaryAction={renderSecondaryAction} diff --git a/packages/danmaku-anywhere/src/common/components/MultiselectChip.tsx b/packages/danmaku-anywhere/src/common/components/MultiselectChip.tsx new file mode 100644 index 00000000..27cbae05 --- /dev/null +++ b/packages/danmaku-anywhere/src/common/components/MultiselectChip.tsx @@ -0,0 +1,32 @@ +import { CheckBox, CheckBoxOutlined } from '@mui/icons-material' +import { Chip, Stack, Typography } from '@mui/material' +import { useTranslation } from 'react-i18next' + +interface MultiselectChipProps { + active: boolean + onToggle: () => void +} + +export const MultiselectChip = ({ active, onToggle }: MultiselectChipProps) => { + const { t } = useTranslation() + + return ( + + {active ? ( + + ) : ( + + )} + + {t('common.multiselect', 'Multiselect')} + + + } + onClick={onToggle} + color="primary" + /> + ) +} diff --git a/packages/danmaku-anywhere/src/common/components/SelectionBottomBar.tsx b/packages/danmaku-anywhere/src/common/components/SelectionBottomBar.tsx new file mode 100644 index 00000000..72c4717a --- /dev/null +++ b/packages/danmaku-anywhere/src/common/components/SelectionBottomBar.tsx @@ -0,0 +1,53 @@ +import { Button, Collapse, Paper, Stack, Typography } from '@mui/material' +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' + +interface SelectionBottomBarProps { + open: boolean + selectionCount: number + onCancel: () => void + children: ReactNode +} + +export const SelectionBottomBar = ({ + open, + selectionCount, + onCancel, + children, +}: SelectionBottomBarProps) => { + const { t } = useTranslation() + + return ( + + + + + + + {t('mountPage.selectedCount', '{{count}} selected', { + count: selectionCount, + })} + + + + {children} + + + + + ) +} diff --git a/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingList.tsx b/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingList.tsx index 29c42606..caa4bf84 100644 --- a/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingList.tsx +++ b/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingList.tsx @@ -1,12 +1,18 @@ -import { Chip } from '@mui/material' +import { Delete } from '@mui/icons-material' +import { Chip, IconButton, Tooltip } from '@mui/material' import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { DraggableList } from '@/common/components/DraggableList' import { ListItemPrimaryStack } from '@/common/components/ListItemPrimaryStack' import type { SeasonMap } from '@/common/seasonMap/SeasonMap' type TitleMappingListProps = { mappings: SeasonMap[] + multiselect?: boolean + selectedIds?: string[] + onSelectionChange?: (ids: string[]) => void onSelect: (map: SeasonMap) => void + onDelete: (key: string) => void } interface DraggableSeasonMap { @@ -16,8 +22,13 @@ interface DraggableSeasonMap { export const TitleMappingList = ({ mappings, + multiselect, + selectedIds, + onSelectionChange, onSelect, + onDelete, }: TitleMappingListProps) => { + const { t } = useTranslation() const items: DraggableSeasonMap[] = useMemo( () => mappings.map((map) => ({ id: map.key, original: map })), [mappings] @@ -27,6 +38,9 @@ export const TitleMappingList = ({ items={items} clickable + multiselect={multiselect} + selectedIds={selectedIds} + onSelectionChange={onSelectionChange} onEdit={(item) => onSelect(item.original)} disableReorder renderPrimary={(item) => ( @@ -34,7 +48,13 @@ export const TitleMappingList = ({ )} - renderSecondaryAction={() => null} + renderSecondaryAction={(item) => ( + + onDelete(item.id)}> + + + + )} /> ) } diff --git a/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingPageCore.tsx b/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingPageCore.tsx index 92825024..4a44a30d 100644 --- a/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingPageCore.tsx +++ b/packages/danmaku-anywhere/src/common/components/TitleMapping/TitleMappingPageCore.tsx @@ -1,9 +1,16 @@ -import { Typography } from '@mui/material' +import { Delete } from '@mui/icons-material' +import { Button, Checkbox, Collapse, Typography } from '@mui/material' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useDialog } from '@/common/components/Dialog/dialogStore' import { TabLayout } from '@/common/components/layout/TabLayout' import { TabToolbar } from '@/common/components/layout/TabToolbar' -import { useAllSeasonMap } from '@/common/seasonMap/queries/useAllSeasonMap' +import { MultiselectChip } from '@/common/components/MultiselectChip' +import { SelectionBottomBar } from '@/common/components/SelectionBottomBar' +import { + useAllSeasonMap, + useSeasonMapMutations, +} from '@/common/seasonMap/queries/useAllSeasonMap' import type { SeasonMap } from '@/common/seasonMap/SeasonMap' import { TitleMappingDetails } from './TitleMappingDetails' import { TitleMappingList } from './TitleMappingList' @@ -19,7 +26,11 @@ export const TitleMappingPageCore = ({ }: TitleMappingPageCoreProps) => { const { t } = useTranslation() const { data: mappings } = useAllSeasonMap() + const mutations = useSeasonMapMutations() + const dialog = useDialog() const [selectedMapping, setSelectedMapping] = useState(null) + const [multiselect, setMultiselect] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) const handleBack = () => { if (selectedMapping) { @@ -39,6 +50,68 @@ export const TitleMappingPageCore = ({ } }, [selectedMapping, activeMapping]) + const handleToggleMultiselect = () => { + if (multiselect) { + setMultiselect(false) + setSelectedIds([]) + } else { + setMultiselect(true) + } + } + + const handleCancelMultiselect = () => { + setMultiselect(false) + setSelectedIds([]) + } + + const allSelected = + mappings.length > 0 && selectedIds.length === mappings.length + const someSelected = + selectedIds.length > 0 && selectedIds.length < mappings.length + + const handleSelectAll = () => { + if (allSelected) { + setSelectedIds([]) + } else { + setSelectedIds(mappings.map((m) => m.key)) + } + } + + const handleDeleteOne = (key: string) => { + dialog.delete({ + title: t('common.delete', 'Delete'), + content: t( + 'titleMapping.deleteConfirmOne', + 'Are you sure you want to delete this title mapping?' + ), + onConfirm: async () => { + await mutations.delete.mutateAsync(key) + }, + }) + } + + const handleDeleteSelected = () => { + if (selectedIds.length === 0) { + return + } + + dialog.delete({ + title: t('common.delete', 'Delete'), + content: t( + 'titleMapping.deleteConfirm', + 'Are you sure you want to delete {{count}} title mapping(s)?', + { count: selectedIds.length } + ), + onConfirm: async () => { + await mutations.deleteMany.mutateAsync(selectedIds) + setSelectedIds([]) + setMultiselect(false) + }, + }) + } + + const showListView = !activeMapping + return ( + leftElement={ + showListView ? ( + + + + ) : undefined + } + > + {showListView && mappings.length > 0 && ( + + )} + {activeMapping ? ( ) : mappings.length === 0 ? ( @@ -59,9 +152,29 @@ export const TitleMappingPageCore = ({ ) : ( setSelectedMapping(map)} + onDelete={handleDeleteOne} /> )} + + + ) } diff --git a/packages/danmaku-anywhere/src/common/localization/locales/en/translation.json b/packages/danmaku-anywhere/src/common/localization/locales/en/translation.json index 942da3be..77823cb6 100644 --- a/packages/danmaku-anywhere/src/common/localization/locales/en/translation.json +++ b/packages/danmaku-anywhere/src/common/localization/locales/en/translation.json @@ -611,6 +611,9 @@ "titleMapping": "Title Mapping" }, "titleMapping": { + "deleteConfirm_one": "Are you sure you want to delete {{count}} title mapping?", + "deleteConfirm_other": "Are you sure you want to delete {{count}} title mappings?", + "deleteConfirmOne": "Are you sure you want to delete this title mapping?", "empty": "No title mappings found.", "noSeasons": "No options for the selected provider", "title": "Title Mappings", diff --git a/packages/danmaku-anywhere/src/common/localization/locales/zh/translation.json b/packages/danmaku-anywhere/src/common/localization/locales/zh/translation.json index 4812b5ec..0580b386 100644 --- a/packages/danmaku-anywhere/src/common/localization/locales/zh/translation.json +++ b/packages/danmaku-anywhere/src/common/localization/locales/zh/translation.json @@ -597,6 +597,8 @@ "titleMapping": "标题映射" }, "titleMapping": { + "deleteConfirm_other": "确定要删除 {{count}} 个标题映射吗?", + "deleteConfirmOne": "确定要删除此标题映射吗?", "empty": "没有标题映射", "noSeasons": "没有可以映射的番剧", "title": "标题映射", diff --git a/packages/danmaku-anywhere/src/common/rpcClient/background/types.ts b/packages/danmaku-anywhere/src/common/rpcClient/background/types.ts index ef69b6fa..23f09b47 100644 --- a/packages/danmaku-anywhere/src/common/rpcClient/background/types.ts +++ b/packages/danmaku-anywhere/src/common/rpcClient/background/types.ts @@ -111,6 +111,7 @@ export type BackgroundMethods = { episodeImport: RPCDef seasonMapAdd: RPCDef seasonMapDelete: RPCDef<{ key: string }, void> + seasonMapDeleteMany: RPCDef<{ keys: string[] }, void> seasonMapGetAll: RPCDef danmakuPurgeCache: RPCDef diff --git a/packages/danmaku-anywhere/src/common/seasonMap/queries/useAllSeasonMap.ts b/packages/danmaku-anywhere/src/common/seasonMap/queries/useAllSeasonMap.ts index a7e420c2..8e0bd46b 100644 --- a/packages/danmaku-anywhere/src/common/seasonMap/queries/useAllSeasonMap.ts +++ b/packages/danmaku-anywhere/src/common/seasonMap/queries/useAllSeasonMap.ts @@ -27,5 +27,11 @@ export const useSeasonMapMutations = () => { return chromeRpcClient.seasonMapDelete({ key }) }, }), + deleteMany: useMutation({ + mutationKey: seasonMapQueryKeys.all(), + mutationFn: async (keys: string[]) => { + return chromeRpcClient.seasonMapDeleteMany({ keys }) + }, + }), } }