Skip to content

Commit

Permalink
feat: Supports batch management of games
Browse files Browse the repository at this point in the history
  • Loading branch information
ximu3 committed Jan 13, 2025
1 parent 5a90efa commit 94728ed
Show file tree
Hide file tree
Showing 12 changed files with 560 additions and 36 deletions.
2 changes: 1 addition & 1 deletion src/renderer/src/components/Game/Config/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function Config({ gameId }: { gameId: string }): JSX.Element {
<AttributesDialog gameId={gameId} setIsOpen={setIsAttributesDialogOpen} />
)}
{isAddCollectionDialogOpen && (
<AddCollectionDialog gameId={gameId} setIsOpen={setIsAddCollectionDialogOpen} />
<AddCollectionDialog gameIds={[gameId]} setIsOpen={setIsAddCollectionDialogOpen} />
)}
</>
)
Expand Down
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>
)
}
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>
)
}
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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './main'
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>
)
}
Loading

0 comments on commit 94728ed

Please sign in to comment.