feature: add collections section

This commit is contained in:
Tasheron 2024-07-06 14:56:35 +03:00
parent 6fce60f9f7
commit b7cabfdbde
26 changed files with 485 additions and 7 deletions

View file

@ -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",

View file

@ -192,6 +192,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}} готова к установке",

View file

@ -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,

View file

@ -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[];
}

View file

@ -6,12 +6,14 @@ import {
UpdateDateColumn,
OneToOne,
JoinColumn,
ManyToMany,
} from "typeorm";
import { Repack } from "./repack.entity";
import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared";
import type { DownloadQueue } from "./download-queue.entity";
import { Collection } from "./collection.entity";
@Entity("game")
export class Game {
@ -79,6 +81,9 @@ export class Game {
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@ManyToMany("Collection", "games")
collections: Collection[];
@Column("boolean", { default: false })
isDeleted: boolean;

View file

@ -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";

View file

@ -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);

View file

@ -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);

View file

@ -0,0 +1,17 @@
import { collectionRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getCollections = async () =>
collectionRepository.find({
relations: {
games: {
downloadQueue: true,
repack: true,
},
},
order: {
title: "asc",
},
});
registerEvent("getCollections", getCollections);

View file

@ -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);

View file

@ -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);

View file

@ -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";

View file

@ -8,6 +8,7 @@ const getLibrary = async () =>
},
relations: {
downloadQueue: true,
collections: true,
},
order: {
createdAt: "desc",

View file

@ -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);

View file

@ -9,6 +9,8 @@ import type {
AppUpdaterEvent,
StartGameDownloadPayload,
GameRunning,
Collection,
Game,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
@ -102,6 +104,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),

View file

@ -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<LibraryGame[]>([]);
@ -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();
@ -48,6 +51,10 @@ export function Sidebar() {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
useEffect(() => {
updateCollections();
});
const isDownloading = sortedLibrary.some(
(game) => game.status === "active" && game.progress !== 1
);
@ -67,17 +74,19 @@ export function Sidebar() {
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (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);
setFilteredLibrary(
sortedLibrary.filter((game) => !game.collections.length)
);
}, [sortedLibrary]);
useEffect(() => {
@ -199,6 +208,52 @@ export function Sidebar() {
theme="dark"
/>
{collections.map((collection) =>
collection.games?.length && showCollections ? (
<section className={styles.section} key={collection.id}>
<small className={styles.sectionTitle}>
{collection.title}
</small>
<ul className={styles.menu}>
{collection.games.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) =>
handleSidebarGameClick(event, game)
}
>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
) : null
)}
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li

View file

@ -14,6 +14,7 @@ import type {
RealDebridUser,
DownloadSource,
UserProfile,
Collection,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -78,6 +79,13 @@ declare global {
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
/* Collections */
addCollection: (title: string) => Promise<void>;
addCollectionGame: (id: number, game: Game) => Promise<void>;
getCollections: () => Promise<Collection[]>;
removeCollection: (collection: Collection) => Promise<void>;
removeCollectionGame: (id: number, game: Game) => Promise<void>;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (

View file

@ -0,0 +1,26 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { Collection } from "../../../types/index";
export interface CollectionsState {
value: Collection[];
}
const initialState: CollectionsState = {
value: [],
};
export const collectionsSlice = createSlice({
name: "collections",
initialState,
reducers: {
setCollections: (
state,
action: PayloadAction<CollectionsState["value"]>
) => {
state.value = action.payload;
},
},
});
export const { setCollections } = collectionsSlice.actions;

View file

@ -6,3 +6,4 @@ export * from "./window-slice";
export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./running-game-slice";
export * from "./collections-slice";

View file

@ -4,3 +4,4 @@ export * from "./use-date";
export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-collections";

View file

@ -0,0 +1,64 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import { setCollections } from "@renderer/features";
import { Collection, Game } from "@types";
import { useToast } from "./use-toast";
import { useTranslation } from "react-i18next";
export function useCollections() {
const { t } = useTranslation("collections");
const dispatch = useAppDispatch();
const collections = useAppSelector((state) => state.collections.value);
const { showSuccessToast, showErrorToast } = useToast();
const updateCollections = useCallback(async () => {
return window.electron
.getCollections()
.then((updatedCollection) => dispatch(setCollections(updatedCollection)));
}, [dispatch]);
const addCollection = async (title: string) => {
if (
!collections.some((collection) => collection.title === title) &&
title !== ""
) {
await window.electron.addCollection(title);
updateCollections();
showSuccessToast(t("the_collection_has_been_added_successfully"));
} else {
showErrorToast(t("you_cant_give_collections_existing_or_empty_names"));
}
};
const removeCollection = async (collection: Collection) => {
await window.electron.removeCollection(collection);
updateCollections();
showSuccessToast(t("the_collection_has_been_removed_successfully"));
};
const addCollectionGame = async (collectionId: number, game: Game) => {
await window.electron.addCollectionGame(collectionId, game);
updateCollections();
showSuccessToast(t("the_game_has_been_added_to_the_collection"));
};
const removeCollectionGame = async (collectionId: number, game: Game) => {
await window.electron.removeCollectionGame(collectionId, game);
updateCollections();
showSuccessToast(t("the_game_has_been_removed_from_the_collection"));
};
return {
collections,
updateCollections,
addCollection,
removeCollection,
addCollectionGame,
removeCollectionGame,
};
}

View file

@ -0,0 +1,25 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../../theme.css";
export const collectionsContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "column",
width: "50%",
margin: "auto",
});
export const buttonsContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "row",
});
export const buttonSelect = style({
flex: 3,
});
export const buttonRemove = style({
flex: 1,
});

View file

@ -0,0 +1,108 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Collection, Game } from "@types";
import * as styles from "./collections-modal.css";
import { useCollections } from "@renderer/hooks/use-collections";
import { useLibrary } from "@renderer/hooks";
export interface CollectionsModalProps {
visible: boolean;
game: Game;
onClose: () => void;
}
export function CollectionsModal({
visible,
game,
onClose,
}: CollectionsModalProps) {
const { t } = useTranslation("collections");
const {
collections,
addCollection,
removeCollection,
addCollectionGame,
removeCollectionGame,
} = useCollections();
const { updateLibrary } = useLibrary();
const [collectionTitle, setcollectionTitle] = useState<string>("");
const handleAddCollection = () => {
addCollection(collectionTitle);
setcollectionTitle("");
};
const handleRemoveCollection = (collection: Collection) => {
removeCollection(collection);
updateLibrary();
};
const handleSetCollection = (id: number, addOrRemove: boolean) => {
addOrRemove ? addCollectionGame(id, game) : removeCollectionGame(id, game);
updateLibrary();
};
return (
<>
<Modal
visible={visible}
title={t("collections")}
onClose={onClose}
large={true}
>
<div className={styles.collectionsContainer}>
<TextField
value={collectionTitle}
theme="dark"
placeholder={t("enter_the_name_of_the_collection")}
onChange={(e) => setcollectionTitle(e.target.value)}
rightContent={
<Button
type="button"
theme="outline"
onClick={handleAddCollection}
>
{t("add")}
</Button>
}
/>
{collections.map((collection) => (
<div className={styles.buttonsContainer} key={collection.id}>
<Button
className={styles.buttonSelect}
type="button"
theme={
collection.games?.some(
(collectionGame) => collectionGame.id == game.id
)
? "primary"
: "outline"
}
onClick={() =>
handleSetCollection(
collection.id,
!collection.games?.some(
(collectionGame) => collectionGame.id == game.id
)
)
}
>
{collection.title}
</Button>
<Button
className={styles.buttonRemove}
type="button"
theme="danger"
onClick={() => handleRemoveCollection(collection)}
>
{t("remove")}
</Button>
</div>
))}
</div>
</Modal>
</>
);
}

View file

@ -7,6 +7,7 @@ import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { CollectionsModal } from "./collections-modal";
export interface GameOptionsModalProps {
visible: boolean;
@ -19,7 +20,7 @@ export function GameOptionsModal({
game,
onClose,
}: GameOptionsModalProps) {
const { t } = useTranslation("game_details");
const { t } = useTranslation(["game_details", "collections"]);
const { showSuccessToast, showErrorToast } = useToast();
@ -28,6 +29,7 @@ export function GameOptionsModal({
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
const [showCollectionsModal, setShowCollectionsModal] = useState(false);
const {
removeGameInstaller,
@ -107,6 +109,29 @@ export function GameOptionsModal({
large={true}
>
<div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<h2>{t("collections:collections")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("collections:add_the_game_to_the_collection")}
</h4>
</div>
<Button
type="button"
theme="outline"
onClick={() => setShowCollectionsModal(true)}
>
{t("collections:select_a_collection")}
</Button>
<CollectionsModal
visible={showCollectionsModal}
game={game}
onClose={() => {
setShowCollectionsModal(false);
}}
/>
<div className={styles.gameOptionHeader}>
<h2>{t("executable_section_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>

View file

@ -3,6 +3,7 @@ import {
downloadSlice,
windowSlice,
librarySlice,
collectionsSlice,
searchSlice,
userPreferencesSlice,
toastSlice,
@ -15,6 +16,7 @@ export const store = configureStore({
search: searchSlice.reducer,
window: windowSlice.reducer,
library: librarySlice.reducer,
collections: collectionsSlice.reducer,
userPreferences: userPreferencesSlice.reducer,
download: downloadSlice.reducer,
toast: toastSlice.reducer,

View file

@ -128,12 +128,19 @@ export interface Game {
objectID: string;
shop: GameShop;
downloadQueue: DownloadQueue | null;
collections: Collection[];
createdAt: Date;
updatedAt: Date;
}
export type LibraryGame = Omit<Game, "repacks">;
export interface Collection {
id: number;
title: string;
games: Game[];
}
export interface GameRunning {
id: number;
title: string;