-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Supports batch management of games
- Loading branch information
Showing
12 changed files
with
560 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
102 changes: 102 additions & 0 deletions
102
src/renderer/src/components/GameBatchEditor/BatchGameNavCM/CollectionMenu.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<ContextMenuGroup> | ||
<ContextMenuSub> | ||
<ContextMenuSubTrigger>批量添加至</ContextMenuSubTrigger> | ||
<ContextMenuPortal> | ||
<ContextMenuSubContent> | ||
{Object.entries(collections) | ||
// Filter out favorites that all selected games are already in | ||
.filter(([key]) => !collectionsStatus.inAll.includes(key)) | ||
.map(([key, value]) => ( | ||
<ContextMenuItem key={key} onClick={() => handleAddToCollection(key)}> | ||
<div className={cn('flex flex-row gap-2 items-center w-full')}>{value.name}</div> | ||
</ContextMenuItem> | ||
))} | ||
|
||
{Object.entries(collections).filter(([key]) => !collectionsStatus.inAll.includes(key)) | ||
.length > 0 && <ContextMenuSeparator />} | ||
|
||
<ContextMenuItem onSelect={openAddCollectionDialog}> | ||
<div className={cn('flex flex-row gap-2 items-center w-full')}> | ||
<span className={cn('icon-[mdi--add] w-4 h-4')}></span> | ||
<div>新收藏</div> | ||
</div> | ||
</ContextMenuItem> | ||
</ContextMenuSubContent> | ||
</ContextMenuPortal> | ||
</ContextMenuSub> | ||
|
||
{(collectionsStatus.inAll.length > 0 || collectionsStatus.inSome.length > 0) && ( | ||
<ContextMenuSub> | ||
<ContextMenuSubTrigger>批量移除出</ContextMenuSubTrigger> | ||
<ContextMenuPortal> | ||
<ContextMenuSubContent> | ||
{Object.entries(collections) | ||
.filter( | ||
([key]) => | ||
collectionsStatus.inAll.includes(key) || collectionsStatus.inSome.includes(key) | ||
) | ||
.map(([key, value]) => ( | ||
<ContextMenuItem key={key} onSelect={() => handleRemoveFromCollection(key)}> | ||
<div className={cn('flex flex-row gap-2 items-center w-full')}> | ||
{value.name} | ||
</div> | ||
</ContextMenuItem> | ||
))} | ||
</ContextMenuSubContent> | ||
</ContextMenuPortal> | ||
</ContextMenuSub> | ||
)} | ||
</ContextMenuGroup> | ||
) | ||
} |
93 changes: 93 additions & 0 deletions
93
src/renderer/src/components/GameBatchEditor/BatchGameNavCM/DeleteGameAlert.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<void> { | ||
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 ( | ||
<AlertDialog> | ||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger> | ||
<AlertDialogContent> | ||
<AlertDialogHeader> | ||
<AlertDialogTitle>{`确定要删除这 ${gameIds.length} 个游戏吗?`}</AlertDialogTitle> | ||
<AlertDialogDescription> | ||
{'删除后,所选游戏的所有数据将被永久删除。此操作不可逆。'} | ||
{gameIds.length > 1 && ( | ||
<div className="mt-2"> | ||
<div className="font-semibold mb-2">将要删除的游戏:</div> | ||
<div className="max-h-32 overflow-y-auto text-sm scrollbar-base"> | ||
{gameNames.map((name, index) => ( | ||
<div key={gameIds[index]} className="mb-1"> | ||
{name || '未命名游戏'} | ||
</div> | ||
))} | ||
</div> | ||
</div> | ||
)} | ||
</AlertDialogDescription> | ||
</AlertDialogHeader> | ||
<AlertDialogFooter> | ||
<AlertDialogCancel>取消</AlertDialogCancel> | ||
<AlertDialogAction onClick={deleteGames}>确定</AlertDialogAction> | ||
</AlertDialogFooter> | ||
</AlertDialogContent> | ||
</AlertDialog> | ||
) | ||
} |
141 changes: 141 additions & 0 deletions
141
src/renderer/src/components/GameBatchEditor/BatchGameNavCM/InformationDialog.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string[]>([]) | ||
const [publishers, setPublishers] = useState<string[]>([]) | ||
const [genres, setGenres] = useState<string[]>([]) | ||
const [platforms, setPlatforms] = useState<string[]>([]) | ||
|
||
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<void> => { | ||
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 ( | ||
<Dialog open={isOpen} onOpenChange={setIsOpen}> | ||
<DialogTrigger className={cn('w-full')}>{children}</DialogTrigger> | ||
<DialogContent className={cn('flex flex-col gap-5')}> | ||
<div className={cn('flex flex-col gap-3 p-4')}> | ||
<div className={cn('flex flex-row gap-3 items-center justify-start')}> | ||
<div className={cn('w-20')}>增量模式</div> | ||
<Switch checked={isIncremental} onCheckedChange={setIsIncremental} /> | ||
</div> | ||
<div className={cn('flex flex-row gap-3 items-center justify-center')}> | ||
<div className={cn('w-20')}>开发商</div> | ||
<Tooltip> | ||
<TooltipTrigger className={cn('p-0 max-w-none m-0 w-full')}> | ||
<ArrayInput value={developers} onChange={setDevelopers} placeholder="暂无开发商" /> | ||
</TooltipTrigger> | ||
<TooltipContent side="right"> | ||
<div className={cn('text-xs')}>开发商之间用英文逗号分隔</div> | ||
</TooltipContent> | ||
</Tooltip> | ||
</div> | ||
<div className={cn('flex flex-row gap-3 items-center justify-center')}> | ||
<div className={cn('w-20')}>发行商</div> | ||
<Tooltip> | ||
<TooltipTrigger className={cn('p-0 max-w-none m-0 w-full')}> | ||
<ArrayInput value={publishers} onChange={setPublishers} placeholder="暂无发行商" /> | ||
</TooltipTrigger> | ||
<TooltipContent side="right"> | ||
<div className={cn('text-xs')}>发行商之间用英文逗号分隔</div> | ||
</TooltipContent> | ||
</Tooltip> | ||
</div> | ||
<div className={cn('flex flex-row gap-3 items-center justify-center')}> | ||
<div className={cn('w-20')}>平台</div> | ||
<Tooltip> | ||
<TooltipTrigger className={cn('p-0 max-w-none m-0 w-full')}> | ||
<ArrayInput value={platforms} onChange={setPlatforms} placeholder="暂无平台" /> | ||
</TooltipTrigger> | ||
<TooltipContent side="right"> | ||
<div className={cn('text-xs')}>平台之间用英文逗号分隔</div> | ||
</TooltipContent> | ||
</Tooltip> | ||
</div> | ||
<div className={cn('flex flex-row gap-3 items-center justify-center')}> | ||
<div className={cn('w-20')}>类型</div> | ||
<Tooltip> | ||
<TooltipTrigger className={cn('p-0 max-w-none m-0 w-full')}> | ||
<ArrayInput value={genres} onChange={setGenres} placeholder="暂无类型" /> | ||
</TooltipTrigger> | ||
<TooltipContent side="right"> | ||
<div className={cn('text-xs')}>类型之间用英文逗号分隔</div> | ||
</TooltipContent> | ||
</Tooltip> | ||
</div> | ||
<div className={cn('flex flex-row-reverse -mb-3 mt-2')}> | ||
<Button onClick={handleConfirm}>确认</Button> | ||
</div> | ||
</div> | ||
</DialogContent> | ||
</Dialog> | ||
) | ||
} |
1 change: 1 addition & 0 deletions
1
src/renderer/src/components/GameBatchEditor/BatchGameNavCM/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './main' |
43 changes: 43 additions & 0 deletions
43
src/renderer/src/components/GameBatchEditor/BatchGameNavCM/main.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<ContextMenuContent className={cn('w-40')}> | ||
<CollectionMenu gameIds={gameIds} openAddCollectionDialog={openAddCollectionDialog} /> | ||
<ContextMenuSeparator /> | ||
<InformationDialog | ||
gameIds={gameIds} | ||
isOpen={isInformationDialogOpen} | ||
setIsOpen={setIsInformationDialogOpen} | ||
> | ||
<ContextMenuItem | ||
onClick={(e) => { | ||
e.preventDefault() | ||
setIsInformationDialogOpen(true) | ||
}} | ||
> | ||
<div>批量修改游戏信息</div> | ||
</ContextMenuItem> | ||
</InformationDialog> | ||
<ContextMenuSeparator /> | ||
<DeleteGameAlert gameIds={gameIds}> | ||
<ContextMenuItem onSelect={(e) => e.preventDefault()}> | ||
<div>批量删除</div> | ||
</ContextMenuItem> | ||
</DeleteGameAlert> | ||
</ContextMenuContent> | ||
) | ||
} |
Oops, something went wrong.