From 94728ed01f14b400652aef036a0c3b7030996f7a Mon Sep 17 00:00:00 2001 From: ximu3 Date: Mon, 13 Jan 2025 22:08:26 +0800 Subject: [PATCH] feat: Supports batch management of games --- .../src/components/Game/Config/main.tsx | 2 +- .../BatchGameNavCM/CollectionMenu.tsx | 102 +++++++++++++ .../BatchGameNavCM/DeleteGameAlert.tsx | 93 ++++++++++++ .../BatchGameNavCM/InformationDialog.tsx | 141 ++++++++++++++++++ .../GameBatchEditor/BatchGameNavCM/index.ts | 1 + .../GameBatchEditor/BatchGameNavCM/main.tsx | 43 ++++++ .../src/components/GameBatchEditor/store.ts | 18 +++ .../src/components/Librarybar/GameNav.tsx | 89 +++++++---- .../Showcase/posters/BigGamePoster.tsx | 2 +- .../Showcase/posters/GamePoster.tsx | 2 +- .../components/dialog/AddCollectionDialog.tsx | 6 +- src/renderer/src/hooks/collections.ts | 97 +++++++++++- 12 files changed, 560 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/GameBatchEditor/BatchGameNavCM/CollectionMenu.tsx create mode 100644 src/renderer/src/components/GameBatchEditor/BatchGameNavCM/DeleteGameAlert.tsx create mode 100644 src/renderer/src/components/GameBatchEditor/BatchGameNavCM/InformationDialog.tsx create mode 100644 src/renderer/src/components/GameBatchEditor/BatchGameNavCM/index.ts create mode 100644 src/renderer/src/components/GameBatchEditor/BatchGameNavCM/main.tsx create mode 100644 src/renderer/src/components/GameBatchEditor/store.ts diff --git a/src/renderer/src/components/Game/Config/main.tsx b/src/renderer/src/components/Game/Config/main.tsx index 1a53b00..fa854bd 100644 --- a/src/renderer/src/components/Game/Config/main.tsx +++ b/src/renderer/src/components/Game/Config/main.tsx @@ -41,7 +41,7 @@ export function Config({ gameId }: { gameId: string }): JSX.Element { )} {isAddCollectionDialogOpen && ( - + )} ) diff --git a/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/CollectionMenu.tsx b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/CollectionMenu.tsx new file mode 100644 index 0000000..6b9b6ae --- /dev/null +++ b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/CollectionMenu.tsx @@ -0,0 +1,102 @@ +import { + ContextMenuGroup, + ContextMenuItem, + ContextMenuPortal, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger +} from '@ui/context-menu' +import { useCollections } from '~/hooks' +import { cn } from '~/utils' + +export function CollectionMenu({ + gameIds, + openAddCollectionDialog +}: { + gameIds: string[] + openAddCollectionDialog: () => void +}): JSX.Element { + const { collections, addGamesToCollection, removeGamesFromCollection } = useCollections() + + // Get the collection of all selected games + const collectionsStatus = Object.entries(collections).reduce<{ + inAll: string[] // All selected games in the collection + inSome: string[] // Selected games in the collection + }>( + (acc, [collectionId, collection]) => { + const gamesInCollection = gameIds.filter((gameId) => collection.games.includes(gameId)).length + + if (gamesInCollection === gameIds.length) { + // All selected games are in this collection + acc.inAll.push(collectionId) + } else if (gamesInCollection > 0) { + // Some of the selected games in this collection + acc.inSome.push(collectionId) + } + return acc + }, + { inAll: [], inSome: [] } + ) + + // Batch add games to favorites + const handleAddToCollection = (collectionId: string): void => { + addGamesToCollection(collectionId, gameIds) + } + + // Batch Remove Games from Favorites + const handleRemoveFromCollection = (collectionId: string): void => { + removeGamesFromCollection(collectionId, gameIds) + } + return ( + + + 批量添加至 + + + {Object.entries(collections) + // Filter out favorites that all selected games are already in + .filter(([key]) => !collectionsStatus.inAll.includes(key)) + .map(([key, value]) => ( + handleAddToCollection(key)}> +
{value.name}
+
+ ))} + + {Object.entries(collections).filter(([key]) => !collectionsStatus.inAll.includes(key)) + .length > 0 && } + + +
+ +
新收藏
+
+
+
+
+
+ + {(collectionsStatus.inAll.length > 0 || collectionsStatus.inSome.length > 0) && ( + + 批量移除出 + + + {Object.entries(collections) + .filter( + ([key]) => + collectionsStatus.inAll.includes(key) || collectionsStatus.inSome.includes(key) + ) + .map(([key, value]) => ( + handleRemoveFromCollection(key)}> +
+ {value.name} +
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/DeleteGameAlert.tsx b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/DeleteGameAlert.tsx new file mode 100644 index 0000000..fa761b9 --- /dev/null +++ b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/DeleteGameAlert.tsx @@ -0,0 +1,93 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@ui/alert-dialog' +import { ipcInvoke } from '~/utils' +import { useDBSyncedState } from '~/hooks' +import { toast } from 'sonner' +import { useCollections } from '~/hooks' +import { useLibrarybarStore } from '~/components/Librarybar' +import { useNavigate } from 'react-router-dom' + +export function DeleteGameAlert({ + gameIds, + children +}: { + gameIds: string[] + children: React.ReactNode +}): JSX.Element { + const navigate = useNavigate() + const { removeGamesFromAllCollections } = useCollections() + const { refreshGameList } = useLibrarybarStore() + + // 获取所有游戏的名称 + const gameNames = gameIds.map((gameId) => { + const [name] = useDBSyncedState('', `games/${gameId}/metadata.json`, ['name']) + return name + }) + + async function deleteGames(): Promise { + const gamesCount = gameIds.length + + toast.promise( + async () => { + console.log(`Deleting games: ${gameIds.join(', ')}...`) + + // Remove games from favorites + removeGamesFromAllCollections(gameIds) + + // Deleting games from the database + for (const gameId of gameIds) { + await ipcInvoke('delete-game-from-db', gameId) + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + refreshGameList() + console.log(`Games deleted: ${gameIds.join(', ')}`) + navigate('/library') + }, + { + loading: `正在删除${gamesCount} 个游戏...`, + success: `${gamesCount} 个游戏已删除`, + error: (err) => `删除${gamesCount} 个游戏失败: ${err.message}` + } + ) + } + + return ( + + {children} + + + {`确定要删除这 ${gameIds.length} 个游戏吗?`} + + {'删除后,所选游戏的所有数据将被永久删除。此操作不可逆。'} + {gameIds.length > 1 && ( +
+
将要删除的游戏:
+
+ {gameNames.map((name, index) => ( +
+ {name || '未命名游戏'} +
+ ))} +
+
+ )} +
+
+ + 取消 + 确定 + +
+
+ ) +} diff --git a/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/InformationDialog.tsx b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/InformationDialog.tsx new file mode 100644 index 0000000..97e2f82 --- /dev/null +++ b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/InformationDialog.tsx @@ -0,0 +1,141 @@ +import { Dialog, DialogContent, DialogTrigger } from '@ui/dialog' +import { TooltipContent, TooltipTrigger, Tooltip } from '@ui/tooltip' +import { ArrayInput } from '@ui/array-input' +import { Switch } from '@ui/switch' +import { Button } from '@ui/button' +import { cn } from '~/utils' +import { useDBSyncedState } from '~/hooks' +import { useState } from 'react' +import { toast } from 'sonner' + +export function InformationDialog({ + gameIds, + isOpen, + setIsOpen, + children +}: { + gameIds: string[] + isOpen: boolean + setIsOpen: (value: boolean) => void + children: React.ReactNode +}): JSX.Element { + const [isIncremental, setIsIncremental] = useState(true) + const [developers, setDevelopers] = useState([]) + const [publishers, setPublishers] = useState([]) + const [genres, setGenres] = useState([]) + const [platforms, setPlatforms] = useState([]) + + const gameStates = gameIds.map((gameId) => ({ + developers: useDBSyncedState([''], `games/${gameId}/metadata.json`, ['developers']), + publishers: useDBSyncedState([''], `games/${gameId}/metadata.json`, ['publishers']), + genres: useDBSyncedState([''], `games/${gameId}/metadata.json`, ['genres']), + platforms: useDBSyncedState([''], `games/${gameId}/metadata.json`, ['platforms']) + })) + + const handleConfirm = async (): Promise => { + try { + gameStates.forEach( + ({ + developers: [currentDevelopers, setGameDevelopers], + publishers: [currentPublishers, setGamePublishers], + genres: [currentGenres, setGameGenres], + platforms: [currentPlatforms, setGamePlatforms] + }) => { + if (developers.length > 0) { + setGameDevelopers( + isIncremental ? [...new Set([...currentDevelopers, ...developers])] : developers + ) + } + if (publishers.length > 0) { + setGamePublishers( + isIncremental ? [...new Set([...currentPublishers, ...publishers])] : publishers + ) + } + if (genres.length > 0) { + setGameGenres(isIncremental ? [...new Set([...currentGenres, ...genres])] : genres) + } + if (platforms.length > 0) { + setGamePlatforms( + isIncremental ? [...new Set([...currentPlatforms, ...platforms])] : platforms + ) + } + } + ) + + setIsOpen(false) + setDevelopers([]) + setPublishers([]) + setGenres([]) + setPlatforms([]) + + toast.success('已批量修改游戏信息') + } catch (error) { + console.error('Failed to update games:', error) + if (error instanceof Error) { + toast.error(`Failed to update games: ${error.message}`) + } else { + toast.error('Failed to update games: An unknown error occurred') + } + } + } + return ( + + {children} + +
+
+
增量模式
+ +
+
+
开发商
+ + + + + +
开发商之间用英文逗号分隔
+
+
+
+
+
发行商
+ + + + + +
发行商之间用英文逗号分隔
+
+
+
+
+
平台
+ + + + + +
平台之间用英文逗号分隔
+
+
+
+
+
类型
+ + + + + +
类型之间用英文逗号分隔
+
+
+
+
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/index.ts b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/index.ts new file mode 100644 index 0000000..48d1a75 --- /dev/null +++ b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/index.ts @@ -0,0 +1 @@ +export * from './main' diff --git a/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/main.tsx b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/main.tsx new file mode 100644 index 0000000..c365e7b --- /dev/null +++ b/src/renderer/src/components/GameBatchEditor/BatchGameNavCM/main.tsx @@ -0,0 +1,43 @@ +import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '@ui/context-menu' +import { cn } from '~/utils' +import { CollectionMenu } from './CollectionMenu' +import { InformationDialog } from './InformationDialog' +import { DeleteGameAlert } from './DeleteGameAlert' +import { useState } from 'react' + +export function BatchGameNavCM({ + gameIds, + openAddCollectionDialog +}: { + gameIds: string[] + openAttributesDialog: () => void + openAddCollectionDialog: () => void +}): JSX.Element { + const [isInformationDialogOpen, setIsInformationDialogOpen] = useState(false) + return ( + + + + + { + e.preventDefault() + setIsInformationDialogOpen(true) + }} + > +
批量修改游戏信息
+
+
+ + + e.preventDefault()}> +
批量删除
+
+
+
+ ) +} diff --git a/src/renderer/src/components/GameBatchEditor/store.ts b/src/renderer/src/components/GameBatchEditor/store.ts new file mode 100644 index 0000000..97ca97e --- /dev/null +++ b/src/renderer/src/components/GameBatchEditor/store.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand' + +interface GameBatchEditorStore { + gameIds: string[] + setGameIds: (gameIds: string[]) => void + addGameId: (gameId: string) => void + removeGameId: (gameId: string) => void + clearGameIds: () => void +} + +export const useGameBatchEditorStore = create((set) => ({ + gameIds: [], + setGameIds: (gameIds: string[]): void => set({ gameIds }), + addGameId: (gameId: string): void => set((state) => ({ gameIds: [...state.gameIds, gameId] })), + removeGameId: (gameId: string): void => + set((state) => ({ gameIds: state.gameIds.filter((id) => id !== gameId) })), + clearGameIds: (): void => set({ gameIds: [] }) +})) diff --git a/src/renderer/src/components/Librarybar/GameNav.tsx b/src/renderer/src/components/Librarybar/GameNav.tsx index 781dd3b..0b53aa6 100644 --- a/src/renderer/src/components/Librarybar/GameNav.tsx +++ b/src/renderer/src/components/Librarybar/GameNav.tsx @@ -7,47 +7,86 @@ import { GameImage } from '@ui/game-image' import { AttributesDialog } from '../Game/Config/AttributesDialog' import React from 'react' import { AddCollectionDialog } from '../dialog/AddCollectionDialog' +import { useGameBatchEditorStore } from '../GameBatchEditor/store' +import { BatchGameNavCM } from '../GameBatchEditor/BatchGameNavCM' export function GameNav({ gameId, groupId }: { gameId: string; groupId: string }): JSX.Element { const [gameName] = useDBSyncedState('', `games/${gameId}/metadata.json`, ['name']) const [isAttributesDialogOpen, setIsAttributesDialogOpen] = React.useState(false) const [isAddCollectionDialogOpen, setIsAddCollectionDialogOpen] = React.useState(false) + const { addGameId, removeGameId, clearGameIds, gameIds } = useGameBatchEditorStore() + + // Check if the current game is selected + const isSelected = gameIds.includes(gameId) + + // Check if the batch mode is enabled + const isBatchMode = gameIds.length > 1 + + const handleGameClick = (event: React.MouseEvent): void => { + event.preventDefault() + + if (event.ctrlKey || event.metaKey) { + // If Ctrl/Command is held down, toggles the selection state + if (isSelected) { + removeGameId(gameId) + } else { + addGameId(gameId) + } + } else { + // If Ctrl/Command is not held down, all selections are cleared and only the current item is selected. + clearGameIds() + addGameId(gameId) + } + } return ( <> - + + - setIsAttributesDialogOpen(true)} - openAddCollectionDialog={() => setIsAddCollectionDialogOpen(true)} - /> + {isBatchMode ? ( + setIsAttributesDialogOpen(true)} + openAddCollectionDialog={() => setIsAddCollectionDialogOpen(true)} + /> + ) : ( + setIsAttributesDialogOpen(true)} + openAddCollectionDialog={() => setIsAddCollectionDialogOpen(true)} + /> + )} {isAttributesDialogOpen && ( )} {isAddCollectionDialogOpen && ( - + )} ) diff --git a/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx b/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx index 4839472..9e85bb1 100644 --- a/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx +++ b/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx @@ -95,7 +95,7 @@ export function BigGamePoster({ )} {isAddCollectionDialogOpen && ( - + )} )} {isAddCollectionDialogOpen && ( - + )} > }): JSX.Element { const [name, setName] = useState('') const { addCollection } = useCollections() const addGameToNewCollection = (): void => { - addCollection(name, gameId) + addCollection(name, gameIds) setIsOpen(false) } diff --git a/src/renderer/src/hooks/collections.ts b/src/renderer/src/hooks/collections.ts index 9d381c1..731792a 100644 --- a/src/renderer/src/hooks/collections.ts +++ b/src/renderer/src/hooks/collections.ts @@ -15,12 +15,15 @@ interface Collections { interface CollectionsHook { collections: Collections - addCollection: (name: string, gameId?: string) => Promise + addCollection: (name: string, gameId?: string[]) => Promise removeCollection: (id: string) => void renameCollection: (id: string, name: string) => void addGameToCollection: (collectionId: string, gameId: string) => void + addGamesToCollection: (collectionId: string, gameIds: string[]) => void removeGameFromCollection: (collectionId: string, gameId: string) => void + removeGamesFromCollection: (collectionId: string, gameIds: string[]) => void removeGameFromAllCollections: (gameId: string) => void + removeGamesFromAllCollections: (gameIds: string[]) => void } export function useCollections(): CollectionsHook { @@ -36,16 +39,19 @@ export function useCollections(): CollectionsHook { ) const addCollection = useCallback( - async (name: string, gameId?: string): Promise => { + async (name: string, gameIds?: string[]): Promise => { try { const id = await ipcInvoke('generate-uuid') + const newCollection = { id, name, - games: gameId ? [gameId] : [] + games: gameIds ? [...gameIds] : [] } + const newCollections = { ...collections, [id]: newCollection } typedSetCollections(newCollections) + return id } catch (error) { console.error('Failed to add collection:', error) @@ -121,6 +127,37 @@ export function useCollections(): CollectionsHook { [typedSetCollections, collections] ) + const addGamesToCollection = useCallback( + (collectionId: string, gameIds: string[]): void => { + try { + const collection = collections[collectionId] + + if (!collection) { + throw new Error('Collection not found') + } + + // Use Set to Avoid Adding the Same Game ID Over and Over Again + const updatedGames = new Set([...collection.games, ...gameIds]) + + const newCollection = { + ...collection, + games: Array.from(updatedGames) // Converting a Set back to an Array + } + + const newCollections = { ...collections, [collectionId]: newCollection } + typedSetCollections(newCollections) + } catch (error) { + console.error('Failed to add games to collection:', error) + if (error instanceof Error) { + toast.error(`Failed to add games to collection: ${error.message}`) + } else { + toast.error('Failed to add games to collection: An unknown error occurred') + } + } + }, + [typedSetCollections, collections] + ) + const removeGameFromCollection = useCallback( (collectionId: string, gameId: string): void => { try { @@ -153,9 +190,40 @@ export function useCollections(): CollectionsHook { [removeCollection, typedSetCollections, collections] ) + const removeGamesFromCollection = useCallback( + (collectionId: string, gameIds: string[]): void => { + try { + const collection = collections[collectionId] + if (!collection) { + throw new Error('Collection not found') + } + + const updatedGames = collection.games.filter((id) => !gameIds.includes(id)) + + if (updatedGames.length === 0) { + removeCollection(collectionId) + } else { + const newCollections = { + ...collections, + [collectionId]: { ...collection, games: updatedGames } + } + typedSetCollections(newCollections) + toast.success(`Games removed from collection "${collection.name}"`) + } + } catch (error) { + console.error('Failed to remove games from collection:', error) + if (error instanceof Error) { + toast.error(`Failed to remove games from collection: ${error.message}`) + } else { + toast.error('Failed to remove games from collection: An unknown error occurred') + } + } + }, + [removeCollection, typedSetCollections, collections] + ) + const removeGameFromAllCollections = useCallback( (gameId: string): void => { - //创建副本处理完毕后,只使用一次typedSetCollections const newCollections = { ...collections } for (const [collectionId, collection] of Object.entries(collections)) { const updatedGames = collection.games.filter((id) => id !== gameId) @@ -170,13 +238,32 @@ export function useCollections(): CollectionsHook { [collections, removeGameFromCollection] ) + const removeGamesFromAllCollections = useCallback( + (gameIds: string[]): void => { + const newCollections = { ...collections } + for (const [collectionId, collection] of Object.entries(collections)) { + const updatedGames = collection.games.filter((id) => !gameIds.includes(id)) + if (updatedGames.length === 0) { + delete newCollections[collectionId] + } else { + newCollections[collectionId] = { ...collection, games: updatedGames } + } + } + typedSetCollections(newCollections) + }, + [removeGameFromAllCollections] + ) + return { collections, addCollection, removeCollection, renameCollection, addGameToCollection, + addGamesToCollection, removeGameFromCollection, - removeGameFromAllCollections + removeGamesFromCollection, + removeGameFromAllCollections, + removeGamesFromAllCollections } }