diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3f1bcdcd4..30f0bf554 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -194,6 +194,19 @@ "found_download_option_other": "Found {{countFormatted}} download options", "import": "Import" }, + "collections": { + "collections": "Collections", + "add_the_game_to_the_collection": "Add the game to the collection", + "select_a_collection": "Select a collection", + "enter_the_name_of_the_collection": "Enter the name of the collection", + "add": "Add", + "remove": "Remove", + "you_cant_give_collections_existing_or_empty_names": "You can`t give collections existing or empty names", + "the_collection_has_been_added_successfully": "The collection has been added successfully", + "the_collection_has_been_removed_successfully": "The collection has been removed successfully", + "the_game_has_been_added_to_the_collection": "The game has been added to the collection", + "the_game_has_been_removed_from_the_collection": "The game has been removed from the collection" + }, "notifications": { "download_complete": "Download complete", "game_ready_to_install": "{{title}} is ready to install", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f6f18d11d..902a5b7bd 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -194,6 +194,19 @@ "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", "import": "Импортировать" }, + "collections": { + "collections": "Коллекции", + "add_the_game_to_the_collection": "Добавьте игру в коллекцию", + "select_a_collection": "Выберите коллекцию", + "enter_the_name_of_the_collection": "Введите название коллекции", + "add": "Добавить", + "remove": "Удалить", + "you_cant_give_collections_existing_or_empty_names": "Нельзя давать коллекциям существующие или пустые названия", + "the_collection_has_been_added_successfully": "Коллекция успешно добавлена", + "the_collection_has_been_removed_successfully": "Коллекция успешно удалена", + "the_game_has_been_added_to_the_collection": "Игра добавлена в коллекцию", + "the_game_has_been_removed_from_the_collection": "Игра удалена из коллекции" + }, "notifications": { "download_complete": "Загрузка завершена", "game_ready_to_install": "{{title}} готова к установке", diff --git a/src/main/data-source.ts b/src/main/data-source.ts index b47ce2c09..4dba73b6e 100644 --- a/src/main/data-source.ts +++ b/src/main/data-source.ts @@ -1,5 +1,6 @@ import { DataSource } from "typeorm"; import { + Collection, DownloadQueue, DownloadSource, Game, @@ -19,6 +20,7 @@ export const createDataSource = ( new DataSource({ type: "better-sqlite3", entities: [ + Collection, Game, Repack, UserPreferences, diff --git a/src/main/entity/collection.entity.ts b/src/main/entity/collection.entity.ts new file mode 100644 index 000000000..b81d3cf83 --- /dev/null +++ b/src/main/entity/collection.entity.ts @@ -0,0 +1,21 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToMany, + JoinTable, +} from "typeorm"; +import { Game } from "./game.entity"; + +@Entity("collection") +export class Collection { + @PrimaryGeneratedColumn() + id: number; + + @Column("text", { unique: true }) + title: string; + + @ManyToMany("Game", "collections") + @JoinTable() + games: Game[]; +} diff --git a/src/main/entity/index.ts b/src/main/entity/index.ts index 9cb4f0444..1a79b7219 100644 --- a/src/main/entity/index.ts +++ b/src/main/entity/index.ts @@ -4,4 +4,5 @@ export * from "./user-preferences.entity"; export * from "./game-shop-cache.entity"; export * from "./download-source.entity"; export * from "./download-queue.entity"; +export * from "./collection.entity"; export * from "./user-auth"; diff --git a/src/main/events/collections/add-collection-game.ts b/src/main/events/collections/add-collection-game.ts new file mode 100644 index 000000000..3b693bcbc --- /dev/null +++ b/src/main/events/collections/add-collection-game.ts @@ -0,0 +1,18 @@ +import { collectionRepository } from "@main/repository"; + +import { registerEvent } from "../register-event"; +import { Collection, Game } from "@main/entity"; + +const addCollectionGame = async ( + _event: Electron.IpcMainInvokeEvent, + collectionId: number, + game: Game +) => { + return await collectionRepository + .createQueryBuilder() + .relation(Collection, "games") + .of(collectionId) + .add(game); +}; + +registerEvent("addCollectionGame", addCollectionGame); diff --git a/src/main/events/collections/add-collection.ts b/src/main/events/collections/add-collection.ts new file mode 100644 index 000000000..ae19e15f7 --- /dev/null +++ b/src/main/events/collections/add-collection.ts @@ -0,0 +1,14 @@ +import { collectionRepository } from "@main/repository"; + +import { registerEvent } from "../register-event"; + +const addCollection = async ( + _event: Electron.IpcMainInvokeEvent, + title: string +) => { + return await collectionRepository.insert({ + title: title, + }); +}; + +registerEvent("addCollection", addCollection); diff --git a/src/main/events/collections/get-collections.ts b/src/main/events/collections/get-collections.ts new file mode 100644 index 000000000..c8fbd9dd1 --- /dev/null +++ b/src/main/events/collections/get-collections.ts @@ -0,0 +1,19 @@ +import { collectionRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; + +const getCollections = async () => + collectionRepository.find({ + relations: { + games: true, + }, + select: { + games: { + id: true, + }, + }, + order: { + title: "asc", + }, + }); + +registerEvent("getCollections", getCollections); diff --git a/src/main/events/collections/remove-collection-game.ts b/src/main/events/collections/remove-collection-game.ts new file mode 100644 index 000000000..d5342f19d --- /dev/null +++ b/src/main/events/collections/remove-collection-game.ts @@ -0,0 +1,18 @@ +import { collectionRepository } from "@main/repository"; + +import { registerEvent } from "../register-event"; +import { Collection, Game } from "@main/entity"; + +const removeCollectionGame = async ( + _event: Electron.IpcMainInvokeEvent, + collectionId: number, + game: Game +) => { + return await collectionRepository + .createQueryBuilder() + .relation(Collection, "games") + .of(collectionId) + .remove(game); +}; + +registerEvent("removeCollectionGame", removeCollectionGame); diff --git a/src/main/events/collections/remove-collection.ts b/src/main/events/collections/remove-collection.ts new file mode 100644 index 000000000..789839905 --- /dev/null +++ b/src/main/events/collections/remove-collection.ts @@ -0,0 +1,13 @@ +import { collectionRepository } from "@main/repository"; + +import { registerEvent } from "../register-event"; +import { Collection } from "@main/entity"; + +const removeCollection = async ( + _event: Electron.IpcMainInvokeEvent, + collection: Collection +) => { + return await collectionRepository.remove(collection); +}; + +registerEvent("removeCollection", removeCollection); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dd5e32637..79f4baf10 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -8,6 +8,11 @@ import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-random-game"; import "./catalogue/search-games"; import "./catalogue/search-game-repacks"; +import "./collections/add-collection"; +import "./collections/add-collection-game"; +import "./collections/get-collections"; +import "./collections/remove-collection"; +import "./collections/remove-collection-game"; import "./hardware/get-disk-free-space"; import "./library/add-game-to-library"; import "./library/create-game-shortcut"; diff --git a/src/main/repository.ts b/src/main/repository.ts index 4464e7752..3bf1c5c75 100644 --- a/src/main/repository.ts +++ b/src/main/repository.ts @@ -1,5 +1,6 @@ import { dataSource } from "./data-source"; import { + Collection, DownloadQueue, DownloadSource, Game, @@ -24,3 +25,5 @@ export const downloadSourceRepository = export const downloadQueueRepository = dataSource.getRepository(DownloadQueue); export const userAuthRepository = dataSource.getRepository(UserAuth); + +export const collectionRepository = dataSource.getRepository(Collection); diff --git a/src/preload/index.ts b/src/preload/index.ts index 91722606d..81ccebc60 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,8 @@ import type { AppUpdaterEvent, StartGameDownloadPayload, GameRunning, + Collection, + Game, FriendRequestAction, } from "@types"; @@ -103,6 +105,16 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.removeListener("on-library-batch-complete", listener); }, + /* Collections */ + addCollection: (title: string) => ipcRenderer.invoke("addCollection", title), + addCollectionGame: (id: number, game: Game) => + ipcRenderer.invoke("addCollectionGame", id, game), + getCollections: () => ipcRenderer.invoke("getCollections"), + removeCollection: (collection: Collection) => + ipcRenderer.invoke("removeCollection", collection), + removeCollectionGame: (id: number, game: Game) => + ipcRenderer.invoke("removeCollectionGame", id, game), + /* Hardware */ getDiskFreeSpace: (path: string) => ipcRenderer.invoke("getDiskFreeSpace", path), diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index c5b3f3a91..6a75464c5 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -15,6 +15,7 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; +import { useCollections } from "@renderer/hooks/use-collections"; const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_INITIAL_WIDTH = 250; @@ -25,6 +26,7 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth"); export function Sidebar() { const { t } = useTranslation("sidebar"); const { library, updateLibrary } = useLibrary(); + const { collections, updateCollections } = useCollections(); const navigate = useNavigate(); const [filteredLibrary, setFilteredLibrary] = useState([]); @@ -33,6 +35,7 @@ export function Sidebar() { const [sidebarWidth, setSidebarWidth] = useState( initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH ); + const [showCollections, setShowCollections] = useState(true); const location = useLocation(); @@ -46,7 +49,8 @@ export function Sidebar() { useEffect(() => { updateLibrary(); - }, [lastPacket?.game.id, updateLibrary]); + updateCollections(); + }, [lastPacket?.game.id, updateLibrary, updateCollections]); const isDownloading = sortedLibrary.some( (game) => game.status === "active" && game.progress !== 1 @@ -67,18 +71,27 @@ export function Sidebar() { }; const handleFilter: React.ChangeEventHandler = (event) => { + const val = event.target.value.toLocaleLowerCase(); + setFilteredLibrary( - sortedLibrary.filter((game) => - game.title - .toLowerCase() - .includes(event.target.value.toLocaleLowerCase()) - ) + sortedLibrary.filter((game) => game.title.toLowerCase().includes(val)) ); + + setShowCollections(val == ""); }; useEffect(() => { - setFilteredLibrary(sortedLibrary); - }, [sortedLibrary]); + setFilteredLibrary( + sortedLibrary.filter( + (game) => + !collections.some((collection) => + collection.games.some( + (collectionGame) => collectionGame.id == game.id + ) + ) + ) + ); + }, [sortedLibrary, collections]); useEffect(() => { window.onmousemove = (event: MouseEvent) => { @@ -199,6 +212,58 @@ export function Sidebar() { theme="dark" /> + {collections.map((collection) => + collection.games?.length && showCollections ? ( +
+ + {collection.title} + + +
    + {sortedLibrary + .filter((game) => + collection.games.some( + (collectionGame) => game.id == collectionGame.id + ) + ) + .map((game) => ( +
  • + +
  • + ))} +
+
+ ) : null + )} +