From f746930dcf0002849eeb700ce1cfa32e41fa70b1 Mon Sep 17 00:00:00 2001 From: GravityTwoG Date: Thu, 28 Mar 2024 20:06:44 +0700 Subject: [PATCH 1/4] feat(saves): move folder explorer to separate page --- src/client/config/paths.tsx | 2 + src/client/config/routes.tsx | 19 ++-- src/client/locales/en/pages/mySaves.json | 5 +- src/client/locales/ru/pages/mySaves.json | 5 +- .../LocalSavesPage.tsx} | 24 ++--- .../local-saves-page.module.scss} | 1 + src/client/pages/MySaves/MySavesPage.tsx | 94 ++++++++++++++-- .../MySaves/MySavesWidget/MySavesWidget.tsx | 101 ------------------ ....module.scss => my-saves-page.module.scss} | 0 9 files changed, 119 insertions(+), 132 deletions(-) rename src/client/pages/MySaves/{FolderExplorer/FolderExplorer.tsx => LocalSaves/LocalSavesPage.tsx} (91%) rename src/client/pages/MySaves/{FolderExplorer/folder-explorer.module.scss => LocalSaves/local-saves-page.module.scss} (95%) delete mode 100644 src/client/pages/MySaves/MySavesWidget/MySavesWidget.tsx rename src/client/pages/MySaves/{MySavesWidget/my-saves-widget.module.scss => my-saves-page.module.scss} (100%) diff --git a/src/client/config/paths.tsx b/src/client/config/paths.tsx index ed889ab..de44ee1 100644 --- a/src/client/config/paths.tsx +++ b/src/client/config/paths.tsx @@ -7,6 +7,7 @@ const resetPassword = path("/reset-password"); const profile = path("/"); const mySaves = path("/my-saves"); +const localSaves = mySaves.path("/local"); const mySave = mySaves.path("/:gameStateId"); const sharedSaves = path("/shared-saves"); const publicSaves = path("/public-saves"); @@ -25,6 +26,7 @@ export const paths = { profile, mySaves, + localSaves, mySave, sharedSaves, publicSaves, diff --git a/src/client/config/routes.tsx b/src/client/config/routes.tsx index 868a75a..b9b2d6a 100644 --- a/src/client/config/routes.tsx +++ b/src/client/config/routes.tsx @@ -22,6 +22,7 @@ import SaveIcon from "@/client/ui/icons/Save.svg"; import GamepadIcon from "@/client/ui/icons/Gamepad.svg"; import UsersIcon from "@/client/ui/icons/Users.svg"; import { paths } from "./paths"; +import { LocalSavesPage } from "../pages/MySaves/LocalSaves/LocalSavesPage"; export enum RouteAccess { "ANONYMOUS" = "ANONYMOUS", @@ -103,6 +104,18 @@ export const routes: RouteDescriptor[] = [ icon: , }, }, + { + path: paths.localSaves.pattern, + component: LocalSavesPage, + access: RouteAccess.AUTHENTICATED, + forRoles: [UserRole.USER], + }, + { + path: paths.mySave.pattern, + component: MySavePage, + access: RouteAccess.AUTHENTICATED, + forRoles: [UserRole.USER], + }, { path: paths.sharedSaves.pattern, component: SharedSavesPage, @@ -123,12 +136,6 @@ export const routes: RouteDescriptor[] = [ icon: , }, }, - { - path: paths.mySave.pattern, - component: MySavePage, - access: RouteAccess.AUTHENTICATED, - forRoles: [UserRole.USER], - }, { path: paths.games.pattern, diff --git a/src/client/locales/en/pages/mySaves.json b/src/client/locales/en/pages/mySaves.json index 39a1bf2..ffe297f 100644 --- a/src/client/locales/en/pages/mySaves.json +++ b/src/client/locales/en/pages/mySaves.json @@ -11,5 +11,8 @@ "every-day": "Every day", "every-week": "Every week", "every-month": "Every month", - "no": "No" + "no": "No", + "local-saves": "Local Saves", + "folder": "Folder", + "file": "File" } diff --git a/src/client/locales/ru/pages/mySaves.json b/src/client/locales/ru/pages/mySaves.json index 14c742e..4f668c6 100644 --- a/src/client/locales/ru/pages/mySaves.json +++ b/src/client/locales/ru/pages/mySaves.json @@ -11,5 +11,8 @@ "every-day": "Каждый день", "every-week": "Каждую неделю", "every-month": "Каждый месяц", - "no": "Нет" + "no": "Нет", + "local-saves": "Локальные сохранения", + "folder": "Папка", + "file": "Файл" } diff --git a/src/client/pages/MySaves/FolderExplorer/FolderExplorer.tsx b/src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx similarity index 91% rename from src/client/pages/MySaves/FolderExplorer/FolderExplorer.tsx rename to src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx index 629692f..b1c67c4 100644 --- a/src/client/pages/MySaves/FolderExplorer/FolderExplorer.tsx +++ b/src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { clsx } from "clsx"; -import classes from "./folder-explorer.module.scss"; +import classes from "./local-saves-page.module.scss"; import { useAPIContext } from "@/client/contexts/APIContext"; import { useUIContext } from "@/client/contexts/UIContext"; @@ -10,18 +10,15 @@ import { useUIContext } from "@/client/contexts/UIContext"; import GamepadIcon from "@/client/ui/icons/Gamepad.svg"; import { Button } from "@/client/ui/atoms/Button/Button"; import { Bytes } from "@/client/ui/atoms/Bytes/Bytes"; -import { Paragraph } from "@/client/ui/atoms/Typography"; +import { H1, Paragraph } from "@/client/ui/atoms/Typography"; +import { Container } from "@/client/ui/atoms/Container/Container"; import { List } from "@/client/ui/molecules/List/List"; function last(arr: string[]) { return arr[arr.length - 1]; } -export type FolderExplorerProps = { - stateUploaded: () => void; -}; - -export const FolderExplorer = (props: FolderExplorerProps) => { +export const LocalSavesPage = () => { const { gameStateAPI, osAPI } = useAPIContext(); const { notify } = useUIContext(); const { t } = useTranslation(undefined, { keyPrefix: "pages.mySaves" }); @@ -95,15 +92,18 @@ export const FolderExplorer = (props: FolderExplorerProps) => { }) => { try { await gameStateAPI.uploadState(folder); - props.stateUploaded(); } catch (e) { notify.error(e); } }; return ( -
- Folder: {selectedFolder} + +

{t("local-saves")}

+ + + {t("folder")}: {selectedFolder} +
{parentFolder && ( @@ -146,7 +146,7 @@ export const FolderExplorer = (props: FolderExplorerProps) => { data-type={file.gameId ? "file" : file.type} >

- {file.type === "folder" ? "folder: " : "file: "} + {`${file.type === "folder" ? t("folder") : t("file")}: `} {file.name}

{!!file.size && ( @@ -184,6 +184,6 @@ export const FolderExplorer = (props: FolderExplorerProps) => { )} /> -
+
); }; diff --git a/src/client/pages/MySaves/FolderExplorer/folder-explorer.module.scss b/src/client/pages/MySaves/LocalSaves/local-saves-page.module.scss similarity index 95% rename from src/client/pages/MySaves/FolderExplorer/folder-explorer.module.scss rename to src/client/pages/MySaves/LocalSaves/local-saves-page.module.scss index 96445d2..ead1fa7 100644 --- a/src/client/pages/MySaves/FolderExplorer/folder-explorer.module.scss +++ b/src/client/pages/MySaves/LocalSaves/local-saves-page.module.scss @@ -1,4 +1,5 @@ .FolderExplorer { + margin: 0.5rem 0; .FileInfo { flex: 1; diff --git a/src/client/pages/MySaves/MySavesPage.tsx b/src/client/pages/MySaves/MySavesPage.tsx index 6979c16..bcc0ca3 100644 --- a/src/client/pages/MySaves/MySavesPage.tsx +++ b/src/client/pages/MySaves/MySavesPage.tsx @@ -1,26 +1,98 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { H1 } from "@/client/ui/atoms/Typography"; +import classes from "./my-saves-page.module.scss"; + +import { paths } from "@/client/config/paths"; +import { syncMap } from "./utils"; +import { useAPIContext } from "@/client/contexts/APIContext"; +import { useUIContext } from "@/client/contexts/UIContext"; +import { useResource } from "@/client/lib/hooks/useResource"; + +import { Link } from "wouter"; +import { H1, H2, Paragraph } from "@/client/ui/atoms/Typography"; import { Container } from "@/client/ui/atoms/Container/Container"; -import { FolderExplorer } from "@/client/pages/MySaves/FolderExplorer/FolderExplorer"; -import { MySavesWidget } from "@/client/pages/MySaves/MySavesWidget/MySavesWidget"; +import { ConfirmButton } from "@/client/ui/molecules/ConfirmButton/ConfirmButton"; +import { List } from "@/client/ui/molecules/List/List"; +import { Paginator } from "@/client/ui/molecules/Paginator"; +import { SearchForm } from "@/client/ui/molecules/SearchForm/SearchForm"; +import { CommonLink } from "@/client/ui/atoms/NavLink/CommonLink"; export const MySavesPage = () => { + const { gameStateAPI } = useAPIContext(); + const { notify } = useUIContext(); const { t } = useTranslation(undefined, { keyPrefix: "pages.mySaves" }); - const [onStateUpload, setOnStateUpload] = useState(() => ({ - stateUploaded: () => {}, - })); + const { + query, + resource: saves, + onSearch, + loadResource: loadSaves, + setQuery, + } = useResource(gameStateAPI.getUserStates); + + const onDelete = async (path: string) => { + try { + await gameStateAPI.deleteState(path); + loadSaves(query); + } catch (error) { + notify.error(error); + } + }; return (

{t("my-saves")}

- - setOnStateUpload({ stateUploaded: f })} - /> + {t("local-saves")} + +
+

{t("uploaded-saves")}

+ setQuery({ ...query, searchQuery })} + /> + + save.gameId} + renderElement={(save) => ( + <> +
+ + {save.name} + + + {t("sync")}: {t(syncMap[save.sync])} + +
+ +
+ { + onDelete(save.id); + }} + color="danger" + > + {t("delete-save")}{" "} + +
+ + )} + /> + + loadSaves({ ...query, pageNumber: page })} + /> +
); }; diff --git a/src/client/pages/MySaves/MySavesWidget/MySavesWidget.tsx b/src/client/pages/MySaves/MySavesWidget/MySavesWidget.tsx deleted file mode 100644 index 30971c6..0000000 --- a/src/client/pages/MySaves/MySavesWidget/MySavesWidget.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { clsx } from "clsx"; - -import classes from "./my-saves-widget.module.scss"; - -import { paths } from "@/client/config/paths"; -import { useAPIContext } from "@/client/contexts/APIContext"; -import { useUIContext } from "@/client/contexts/UIContext"; -import { useResource } from "@/client/lib/hooks/useResource"; -import { syncMap } from "../utils"; - -import { Link } from "wouter"; -import { H2, Paragraph } from "@/client/ui/atoms/Typography"; -import { List } from "@/client/ui/molecules/List/List"; -import { Paginator } from "@/client/ui/molecules/Paginator"; -import { SearchForm } from "@/client/ui/molecules/SearchForm/SearchForm"; -import { ConfirmButton } from "@/client/ui/molecules/ConfirmButton/ConfirmButton"; - -export type SavesWidgetProps = { - setOnSaveUpload: (saveUploaded: () => void) => void; - className?: string; -}; - -export const MySavesWidget = (props: SavesWidgetProps) => { - const { gameStateAPI } = useAPIContext(); - const { notify } = useUIContext(); - const { t } = useTranslation(undefined, { keyPrefix: "pages.mySaves" }); - - const { - query, - resource: saves, - onSearch, - loadResource: loadSaves, - setQuery, - } = useResource(gameStateAPI.getUserStates); - - useEffect(() => { - props.setOnSaveUpload(() => loadSaves(query)); - }, [query]); - - const onDelete = async (path: string) => { - try { - await gameStateAPI.deleteState(path); - loadSaves(query); - } catch (error) { - notify.error(error); - } - }; - - return ( -
-

{t("uploaded-saves")}

- setQuery({ ...query, searchQuery })} - /> - - save.gameId} - renderElement={(save) => ( - <> -
- - {save.name} - - - {t("sync")}: {t(syncMap[save.sync])} - -
- -
- { - onDelete(save.id); - }} - color="danger" - > - {t("delete-save")}{" "} - -
- - )} - /> - - loadSaves({ ...query, pageNumber: page })} - /> -
- ); -}; diff --git a/src/client/pages/MySaves/MySavesWidget/my-saves-widget.module.scss b/src/client/pages/MySaves/my-saves-page.module.scss similarity index 100% rename from src/client/pages/MySaves/MySavesWidget/my-saves-widget.module.scss rename to src/client/pages/MySaves/my-saves-page.module.scss From bac6447af6630fea78cb86af6e5d2cce31675345 Mon Sep 17 00:00:00 2001 From: GravityTwoG Date: Thu, 28 Mar 2024 20:57:00 +0700 Subject: [PATCH 2/4] feat(gamestates): save sync settings --- src/client/api/AuthAPI.ts | 4 +- src/client/api/GameStateAPI.ts | 59 ++++++++++++++++--- src/client/api/SyncedSavesAPI.ts | 22 ------- src/client/api/interfaces/IGameStateAPI.ts | 2 + src/client/api/mocks/AuthAPIMock.ts | 2 + src/client/api/mocks/GameStateAPIMock.ts | 55 +++++++++++++++-- src/client/app.tsx | 22 +++++-- .../contexts/AuthContext/AuthContext.ts | 1 + src/client/locales/en/pages/mySave.json | 4 +- src/client/locales/ru/pages/mySave.json | 4 +- .../pages/MySaves/MySave/MySavePage.tsx | 40 +++++++++++-- .../MySaves/MySave/my-save-page.module.scss | 13 ++++ src/client/pages/MySaves/MySavesPage.tsx | 28 +++++---- .../pages/MySaves/my-saves-page.module.scss | 13 ++++ src/types.ts | 3 + 15 files changed, 214 insertions(+), 58 deletions(-) delete mode 100644 src/client/api/SyncedSavesAPI.ts diff --git a/src/client/api/AuthAPI.ts b/src/client/api/AuthAPI.ts index 274fbd0..defa976 100644 --- a/src/client/api/AuthAPI.ts +++ b/src/client/api/AuthAPI.ts @@ -42,13 +42,13 @@ export class AuthAPI implements IAuthAPI { body: credentials, }); - return { ...user, role: roleMap[user.role] }; + return { ...user, role: roleMap[user.role], id: "TODO" }; }; getCurrentUser = async (): Promise => { const user = await this.fetcher.get("/auth/me"); - return { ...user, role: roleMap[user.role] }; + return { ...user, role: roleMap[user.role], id: "TODO" }; }; changePassword = (credentials: ChangePasswordCredentials): Promise => { diff --git a/src/client/api/GameStateAPI.ts b/src/client/api/GameStateAPI.ts index 9597000..624ac5d 100644 --- a/src/client/api/GameStateAPI.ts +++ b/src/client/api/GameStateAPI.ts @@ -71,7 +71,16 @@ export class GameStateAPI implements IGameStateAPI { `${apiPrefix}/${gameStateId}` ); - return this.mapGameStateFromServer(state); + const syncSettings = this.getSyncSettings(); + + const mapped = this.mapGameStateFromServer(state); + + return { + ...mapped, + sync: syncSettings[mapped.id] + ? syncSettings[mapped.id].sync + : GameStateSync.NO, + }; }; getUserStates = async ( @@ -84,8 +93,19 @@ export class GameStateAPI implements IGameStateAPI { `${apiPrefix}?searchQuery=${query.searchQuery}&pageSize=${query.pageSize}&pageNumber=${query.pageNumber}` ); + const syncSettings = this.getSyncSettings(); + return { - items: states.items.map(this.mapGameStateFromServer), + items: states.items.map((state) => { + const mapped = this.mapGameStateFromServer(state); + + return { + ...mapped, + sync: syncSettings[mapped.id] + ? syncSettings[mapped.id].sync + : GameStateSync.NO, + }; + }), totalCount: states.totalCount, }; }; @@ -148,6 +168,7 @@ export class GameStateAPI implements IGameStateAPI { gameId?: string; path: string; name: string; + isPublic: boolean; }): Promise => { const game = state.gameId ? await this.gameAPI.getGame(state.gameId) @@ -163,7 +184,7 @@ export class GameStateAPI implements IGameStateAPI { gameId: state.gameId, name: game ? game.name : state.name, localPath: state.path, - // isPublic: false, + // isPublic: state.isPublic, gameStateValues: response.gameStateValues.map((value) => ({ value: value.value, gameStateParameterId: value.gameStateParameterId, @@ -178,18 +199,39 @@ export class GameStateAPI implements IGameStateAPI { }; setupSync = async (settings: { + userId: string; gameStateId: string; sync: GameStateSync; }) => { try { - const states = ls.getItem>("states"); - states[settings.gameStateId].sync = settings.sync; - ls.setItem("states", states); + const states = ls.getItem>("sync_settings"); + states[settings.gameStateId] = { + ...states[settings.gameStateId], + sync: settings.sync, + }; + ls.setItem("sync_settings", states); } catch (e) { - console.log(e); + ls.setItem("sync_settings", { + [settings.gameStateId]: { + ...settings, + sync: settings.sync, + }, + }); } }; + getSyncSettings(): Record { + try { + const syncSetting = + ls.getItem>( + "sync_settings" + ); + return syncSetting; + } catch (e) { + return {}; + } + } + downloadState = async (path: string) => { await this.osAPI.downloadState(path); }; @@ -209,8 +251,10 @@ export class GameStateAPI implements IGameStateAPI { return { id: state.id.toString(), gameId: state.gameId.toString(), + gameIconURL: state.gameIconUrl, name: state.name, sync: GameStateSync.NO, + isPublic: false, localPath: state.localPath, archiveURL: state.archiveUrl, sizeInBytes: state.sizeInBytes, @@ -227,6 +271,7 @@ export class GameStateAPI implements IGameStateAPI { }; }; + // Shares addShare = async (share: { gameStateId: string; userId: string; diff --git a/src/client/api/SyncedSavesAPI.ts b/src/client/api/SyncedSavesAPI.ts deleted file mode 100644 index 3d59a1c..0000000 --- a/src/client/api/SyncedSavesAPI.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GameState } from "@/types"; -import { LocalStorage } from "./mocks/LocalStorage"; - -const ls = new LocalStorage("game_states_"); - -export class SyncedStatesAPI { - getSyncedStates = async (): Promise => { - try { - const states = ls.getItem>("saves"); - - const statesArray: GameState[] = []; - - for (const key in states) { - statesArray.push(states[key]); - } - - return statesArray; - } catch (e) { - return []; - } - }; -} diff --git a/src/client/api/interfaces/IGameStateAPI.ts b/src/client/api/interfaces/IGameStateAPI.ts index 6c0bcec..990566a 100644 --- a/src/client/api/interfaces/IGameStateAPI.ts +++ b/src/client/api/interfaces/IGameStateAPI.ts @@ -15,9 +15,11 @@ export interface IGameStateAPI { gameId?: string; path: string; name: string; + isPublic: boolean; }): Promise; setupSync(settings: { + userId: string; gameStateId: string; sync: GameStateSync; }): Promise; diff --git a/src/client/api/mocks/AuthAPIMock.ts b/src/client/api/mocks/AuthAPIMock.ts index de9e97f..79588ef 100644 --- a/src/client/api/mocks/AuthAPIMock.ts +++ b/src/client/api/mocks/AuthAPIMock.ts @@ -57,6 +57,7 @@ export class AuthAPIMock implements IAuthAPI { ls.setItem("user", user); return { + id: user.id, email: user.email, username: user.username, role: user.role, @@ -80,6 +81,7 @@ export class AuthAPIMock implements IAuthAPI { ls.setItem("isAuthenticated", "true"); ls.setItem("user", user); return { + id: user.id, email: user.email, username: user.username, role: user.role, diff --git a/src/client/api/mocks/GameStateAPIMock.ts b/src/client/api/mocks/GameStateAPIMock.ts index 5a9d0ff..bc79d06 100644 --- a/src/client/api/mocks/GameStateAPIMock.ts +++ b/src/client/api/mocks/GameStateAPIMock.ts @@ -49,7 +49,16 @@ export class GameStateAPIMock implements IGameStateAPI { getGameState = async (gameStateId: string): Promise => { try { const states = ls.getItem>("states"); - return states[gameStateId]; + const state = states[gameStateId]; + + const syncSettings = this.getSyncSettings(); + + return { + ...state, + sync: syncSettings[state.id] + ? syncSettings[state.id].sync + : GameStateSync.NO, + }; } catch (e) { throw new ApiError("Game state not found"); } @@ -63,8 +72,16 @@ export class GameStateAPIMock implements IGameStateAPI { const states = ls.getItem>("states"); const statesArray: GameState[] = []; + const syncSettings = this.getSyncSettings(); + for (const key in states) { - statesArray.push(states[key]); + const state = states[key]; + statesArray.push({ + ...state, + sync: syncSettings[state.id] + ? syncSettings[state.id].sync + : GameStateSync.NO, + }); } return { @@ -114,9 +131,11 @@ export class GameStateAPIMock implements IGameStateAPI { const gameState: GameState = { id: gameStateId, gameId: state.path, + gameIconURL: "", localPath: state.path, name: game ? game.name : state.name, sync: GameStateSync.NO, + isPublic: false, gameStateValues: response.gameStateValues.map((field) => ({ gameStateParameterId: field.gameStateParameterId, value: field.value, @@ -147,6 +166,7 @@ export class GameStateAPIMock implements IGameStateAPI { gameId?: string; path: string; name: string; + isPublic: boolean; }): Promise => { const game = state.gameId ? await this.gameAPI.getGame(state.gameId) @@ -158,9 +178,11 @@ export class GameStateAPIMock implements IGameStateAPI { const gameState: GameState = { id: gameStateId, gameId: state.path, + gameIconURL: "", localPath: state.path, name: game ? game.name : state.name, sync: GameStateSync.NO, + isPublic: state.isPublic, gameStateValues: response.gameStateValues.map((field) => ({ gameStateParameterId: field.gameStateParameterId, value: field.value, @@ -187,18 +209,39 @@ export class GameStateAPIMock implements IGameStateAPI { }; setupSync = async (settings: { + userId: string; gameStateId: string; sync: GameStateSync; }) => { try { - const states = ls.getItem>("states"); - states[settings.gameStateId].sync = settings.sync; - ls.setItem("states", states); + const states = ls.getItem>("sync_settings"); + states[settings.gameStateId] = { + ...states[settings.gameStateId], + sync: settings.sync, + }; + ls.setItem("sync_settings", states); } catch (e) { - console.log(e); + ls.setItem("sync_settings", { + [settings.gameStateId]: { + ...settings, + sync: settings.sync, + }, + }); } }; + getSyncSettings(): Record { + try { + const syncSetting = + ls.getItem>( + "sync_settings" + ); + return syncSetting; + } catch (e) { + return {}; + } + } + downloadState = async (path: string) => { await this.osAPI.downloadState(path); }; diff --git a/src/client/app.tsx b/src/client/app.tsx index c815dc0..c42e205 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -3,18 +3,32 @@ import { createRoot } from "react-dom/client"; import "./ui/styles/theme.css"; import "./styles/utility.css"; -import { SyncedStatesAPI } from "./api/SyncedSavesAPI"; +import { api } from "./contexts/APIContext/APIContext"; import { ReactApplication } from "./ReactApplication"; import { initI18n } from "./locales"; +import { GameState } from "@/types"; function bootstrap() { initI18n(); - const syncedStatesAPI = new SyncedStatesAPI(); - window.electronAPI.onGetSyncedSaves(async () => { - const states = await syncedStatesAPI.getSyncedStates(); + const statesMap = await api.gameStateAPI.getSyncSettings(); + const states: GameState[] = []; + + for (const gameStateId in statesMap) { + try { + const syncSettings = statesMap[gameStateId]; + const state = await api.gameStateAPI.getGameState(gameStateId); + states.push({ + ...state, + sync: syncSettings.sync, + }); + } catch (error) { + console.error(error); + } + } + window.electronAPI.sendSyncedSaves(states); }); diff --git a/src/client/contexts/AuthContext/AuthContext.ts b/src/client/contexts/AuthContext/AuthContext.ts index c2d3e5b..35fe8f2 100644 --- a/src/client/contexts/AuthContext/AuthContext.ts +++ b/src/client/contexts/AuthContext/AuthContext.ts @@ -9,6 +9,7 @@ import { } from "@/client/api/interfaces/IAuthAPI"; export const emptyUser: User = { + id: "$USER_ID$", email: "$EMAIL$", username: "$NAME$", role: UserRole.USER, diff --git a/src/client/locales/en/pages/mySave.json b/src/client/locales/en/pages/mySave.json index 5e39bb2..552f8b2 100644 --- a/src/client/locales/en/pages/mySave.json +++ b/src/client/locales/en/pages/mySave.json @@ -3,8 +3,9 @@ "save": "Save", "path": "Path", "setup-sync": "Setup Sync", - "is-public-no": "Is public: no", + "is-public": "Is public", "make-public": "Make public", + "make-private": "Make private", "shared-with": "Shared with", "share": "Share", "shares": "Shares", @@ -19,6 +20,7 @@ "every-day": "Every day", "every-week": "Every week", "every-month": "Every month", + "yes": "Yes", "no": "No", "confirm": "Confirm", "about": "About", diff --git a/src/client/locales/ru/pages/mySave.json b/src/client/locales/ru/pages/mySave.json index f47a2d1..ccf439a 100644 --- a/src/client/locales/ru/pages/mySave.json +++ b/src/client/locales/ru/pages/mySave.json @@ -3,8 +3,9 @@ "save": "Сохранить", "path": "Путь", "setup-sync": "Настроить синхронизацию", - "is-public-no": "Публичное: нет", + "is-public": "Публичное", "make-public": "Сделать публичным", + "make-private": "Сделать приватным", "shared-with": "Поделились", "share": "Поделиться", "shares": "Поделиться", @@ -19,6 +20,7 @@ "every-day": "Каждый день", "every-week": "Каждую неделю", "every-month": "Каждый месяц", + "yes": "Да", "no": "Нет", "confirm": "Подтвердить", "about": "Информация", diff --git a/src/client/pages/MySaves/MySave/MySavePage.tsx b/src/client/pages/MySaves/MySave/MySavePage.tsx index 87cc382..d435cd8 100644 --- a/src/client/pages/MySaves/MySave/MySavePage.tsx +++ b/src/client/pages/MySaves/MySave/MySavePage.tsx @@ -7,6 +7,7 @@ import classes from "./my-save-page.module.scss"; import { GameState, GameStateSync, GameStateValue } from "@/types"; import { useAPIContext } from "@/client/contexts/APIContext"; import { useUIContext } from "@/client/contexts/UIContext"; +import { useAuthContext } from "@/client/contexts/AuthContext"; import { navigate } from "@/client/useHashLocation"; import { paths } from "@/client/config/paths"; import { syncMap } from "../utils"; @@ -25,9 +26,10 @@ export const MySavePage = () => { const { t } = useTranslation(undefined, { keyPrefix: "pages.mySave" }); const [gameState, setGameState] = useState(null); - const { gameStateId } = useParams(); const { notify } = useUIContext(); + const { user } = useAuthContext(); + const { gameStateId } = useParams(); useEffect(() => { (async () => { if (!gameStateId) return; @@ -44,7 +46,7 @@ export const MySavePage = () => { const [syncSettingsAreOpen, setSyncSettingsAreOpen] = useState(false); const [sync, setSync] = useState(GameStateSync.NO); - if (!gameState) { + if (!gameState || !gameStateId) { return (

{t("game-save-not-found")}

@@ -55,6 +57,7 @@ export const MySavePage = () => { const setupSync = async () => { try { await gameStateAPI.setupSync({ + userId: user.id, gameStateId: gameState.id, sync: sync, }); @@ -68,6 +71,22 @@ export const MySavePage = () => { } }; + const togglePublicity = async () => { + try { + await gameStateAPI.reuploadState({ + id: gameState.id, + gameId: gameState.gameId, + path: gameState.localPath, + name: gameState.name, + isPublic: !gameState.isPublic, + }); + const data = await gameStateAPI.getGameState(gameStateId); + setGameState(data); + } catch (error) { + notify.error(error); + } + }; + const onReuploadSave = async () => { try { await gameStateAPI.reuploadState({ @@ -75,6 +94,7 @@ export const MySavePage = () => { gameId: gameState.gameId, path: gameState.localPath, name: gameState.name, + isPublic: gameState.isPublic, }); } catch (error) { notify.error(error); @@ -106,7 +126,14 @@ export const MySavePage = () => { return ( -

{gameState?.name || t("save")}

+

+ {gameState.name}{" "} + {gameState?.name || t("save")} +

@@ -125,14 +152,17 @@ export const MySavePage = () => { - {t("is-public-no")} + {t("is-public")}: {gameState.isPublic ? t("yes") : t("no")}{" "} + {t("shared-with")}:
- + { diff --git a/src/client/pages/MySaves/MySave/my-save-page.module.scss b/src/client/pages/MySaves/MySave/my-save-page.module.scss index e130762..8e43c34 100644 --- a/src/client/pages/MySaves/MySave/my-save-page.module.scss +++ b/src/client/pages/MySaves/MySave/my-save-page.module.scss @@ -3,6 +3,19 @@ padding-top: 1rem; } +.GameStateName { + display: flex; + gap: 0.5rem; + align-items: center; + + .GameIcon { + width: 8rem; + height: 6rem; + + object-fit: contain; + } +} + .GameSaveSettings { display: flex; gap: 0.5rem; diff --git a/src/client/pages/MySaves/MySavesPage.tsx b/src/client/pages/MySaves/MySavesPage.tsx index bcc0ca3..ccd6145 100644 --- a/src/client/pages/MySaves/MySavesPage.tsx +++ b/src/client/pages/MySaves/MySavesPage.tsx @@ -59,16 +59,24 @@ export const MySavesPage = () => { getKey={(save) => save.gameId} renderElement={(save) => ( <> -
- - {save.name} - - - {t("sync")}: {t(syncMap[save.sync])} - +
+ {save.name} + +
+ + {save.name} + + + {t("sync")}: {t(syncMap[save.sync])} + +
diff --git a/src/client/pages/MySaves/my-saves-page.module.scss b/src/client/pages/MySaves/my-saves-page.module.scss index bbe8bf2..ded4d2f 100644 --- a/src/client/pages/MySaves/my-saves-page.module.scss +++ b/src/client/pages/MySaves/my-saves-page.module.scss @@ -9,6 +9,19 @@ gap: 0.5rem; } +.GameInfo { + display: flex; + gap: 0.5rem; + align-items: flex-start; + + .GameIcon { + width: 8rem; + height: 6rem; + object-fit: contain; + background-color: var(--deco-color); + } +} + .GameSaveLink { text-decoration: none; color: var(--text-color); diff --git a/src/types.ts b/src/types.ts index deaa4e2..7c9aa62 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export enum UserRole { } export type User = { + id: string; email: string; username: string; role: UserRole; @@ -79,9 +80,11 @@ export type GameStateValue = { export type GameState = { id: string; gameId: string; + gameIconURL: string; name: string; localPath: string; sync: GameStateSync; + isPublic: boolean; gameStateValues: GameStateValue[]; archiveURL: string; From c2d6588698785b63eba5f12978f87d4f3fb610b1 Mon Sep 17 00:00:00 2001 From: GravityTwoG Date: Sat, 30 Mar 2024 22:40:54 +0700 Subject: [PATCH 3/4] feat(api): integrate CommonParameters, Shares API --- src/client/api/CommonParametersAPI.ts | 129 ++++++++++++++++++ src/client/api/GameAPI.ts | 11 +- src/client/api/GameStateAPI.ts | 96 +++++++------ src/client/api/UsersAPI.ts | 41 ++++++ .../mocks/GameStateParameterTypesAPIMock.ts | 8 +- src/client/contexts/APIContext/APIContext.ts | 8 +- .../components/SharesWidget/SharesWidget.tsx | 16 ++- 7 files changed, 250 insertions(+), 59 deletions(-) create mode 100644 src/client/api/CommonParametersAPI.ts create mode 100644 src/client/api/UsersAPI.ts diff --git a/src/client/api/CommonParametersAPI.ts b/src/client/api/CommonParametersAPI.ts new file mode 100644 index 0000000..30529c6 --- /dev/null +++ b/src/client/api/CommonParametersAPI.ts @@ -0,0 +1,129 @@ +import { CommonParameter } from "@/types"; +import { ICommonParametersAPI } from "./interfaces/ICommonParametersAPI"; +import { ResourceRequest, ResourceResponse } from "./interfaces/common"; +import { Fetcher } from "./Fetcher"; + +type CommonParameterFromServer = { + id: number; + label: string; + description: string; + gameStateParameterTypeDTO: { + id: string; + type: string; + }; +}; + +export class CommonParametersAPI implements ICommonParametersAPI { + private readonly fetcher: Fetcher; + + constructor(fetcher: Fetcher) { + this.fetcher = fetcher; + } + + getParameters = async ( + query: ResourceRequest + ): Promise> => { + const parameters = await this.fetcher.get< + ResourceResponse + >( + `/common-parameters?pageNumber=${query.pageNumber}&pageSize=${query.pageSize}&searchQuery=${query.searchQuery}` + ); + + return { + items: parameters.items.map((parameter) => ({ + id: parameter.id.toString(), + label: parameter.label, + description: parameter.description, + type: { + id: parameter.gameStateParameterTypeDTO.id.toString(), + type: parameter.gameStateParameterTypeDTO.type, + }, + })), + totalCount: parameters.totalCount, + }; + }; + + getParameter = async (parameterId: string): Promise => { + const parameter = await this.fetcher.get( + `/common-parameters/${parameterId}` + ); + + return { + id: parameter.id.toString(), + label: parameter.label, + description: parameter.description, + type: { + id: parameter.gameStateParameterTypeDTO.id.toString(), + type: parameter.gameStateParameterTypeDTO.type, + }, + }; + }; + + createParameter = async ( + parameter: CommonParameter + ): Promise => { + const formData = new FormData(); + formData.append( + "commonParameterData", + JSON.stringify({ + label: parameter.label, + description: parameter.description, + gameStateParameterTypeId: parameter.type.id, + }) + ); + + const createdParameter = await this.fetcher.post( + "/common-parameters", + { + headers: {}, + body: formData, + } + ); + return { + id: createdParameter.id.toString(), + label: createdParameter.label, + description: createdParameter.description, + type: { + id: parameter.type.id.toString(), + type: parameter.type.type, + }, + }; + }; + + updateParameter = async ( + parameter: CommonParameter + ): Promise => { + const formData = new FormData(); + formData.append( + "commonParameterData", + JSON.stringify({ + label: parameter.label, + description: parameter.description, + gameStateParameterTypeId: parameter.type.id, + }) + ); + + const updatedParameter = + await this.fetcher.patch( + `/common-parameters/${parameter.id}`, + { + headers: {}, + body: formData, + } + ); + + return { + id: updatedParameter.id.toString(), + label: updatedParameter.label, + description: updatedParameter.description, + type: { + id: parameter.type.id.toString(), + type: parameter.type.type, + }, + }; + }; + + deleteParameter = async (parameterId: string): Promise => { + await this.fetcher.delete(`/common-parameters/${parameterId}`); + }; +} diff --git a/src/client/api/GameAPI.ts b/src/client/api/GameAPI.ts index 5406bb5..e8c67ee 100644 --- a/src/client/api/GameAPI.ts +++ b/src/client/api/GameAPI.ts @@ -21,6 +21,7 @@ type GameFromServer = { type: string; label: string; description: string; + commonParameterId: number; }[]; }; imageUrl: string; @@ -64,7 +65,7 @@ export class GameAPI implements IGameAPI { JSON.stringify({ name: game.name, description: game.description, - paths: game.paths.map((path) => ({ path })), + paths: game.paths, extractionPipeline: game.extractionPipeline, schema: { filename: game.gameStateParameters.filename, @@ -72,6 +73,7 @@ export class GameAPI implements IGameAPI { (field) => ({ key: field.key, type: field.type.type || field.type.id, + commonParameterId: field.commonParameter.id, label: field.label, description: field.description, }) @@ -107,6 +109,7 @@ export class GameAPI implements IGameAPI { id: field.id, key: field.key, type: field.type.type || field.type.id, + commonParameterId: field.commonParameter.id, label: field.label, description: field.description, }) @@ -142,13 +145,13 @@ export class GameAPI implements IGameAPI { id: field.type, }, commonParameter: { - id: field.id.toString(), + id: field.commonParameterId.toString(), type: { type: field.type, id: field.type, }, - label: "label", - description: "description", + label: "", + description: "", }, label: field.label, description: field.description, diff --git a/src/client/api/GameStateAPI.ts b/src/client/api/GameStateAPI.ts index 624ac5d..d0629e3 100644 --- a/src/client/api/GameStateAPI.ts +++ b/src/client/api/GameStateAPI.ts @@ -21,9 +21,23 @@ type GameStateFromServer = { id: number; localPath: string; name: string; + isPublic: boolean; sizeInBytes: number; }; +type ShareFromServer = { + id: string; + username: string; +}; + +type GamePathFromServer = { + id: number; + path: string; + gameId: number; + gameName: string; + gameIconUrl: string; +}; + const apiPrefix = "/game-saves"; export class GameStateAPI implements IGameStateAPI { @@ -38,24 +52,17 @@ export class GameStateAPI implements IGameStateAPI { } getStatePaths = async (): Promise => { - const paths: GamePath[] = []; - - const games = await this.gameAPI.getGames({ - pageNumber: 1, - pageSize: 1000, - searchQuery: "", - }); - - for (const game of games.items) { - for (const path of game.paths) { - paths.push({ - path: path.path, - gameId: game.id, - gameName: game.name, - gameIconURL: game.iconURL, - }); - } - } + const pathsFromServer = await this.fetcher.get<{ + items: GamePathFromServer[][]; + }>(`/game-paths?pageSize=1000&pageNumber=1&searchQuery=""`); + + const paths: GamePath[] = pathsFromServer.items[0].map((path) => ({ + id: path.id.toString(), + path: path.path, + gameId: path.gameId.toString(), + gameName: path.gameName, + gameIconURL: path.gameIconUrl, + })); const response = await this.osAPI.getSavePaths(paths); @@ -149,7 +156,7 @@ export class GameStateAPI implements IGameStateAPI { gameId: state.gameId, name: game ? game.name : state.name, localPath: state.path, - // isPublic: false, + isPublic: false, gameStateValues: response.gameStateValues.map((value) => ({ value: value.value, gameStateParameterId: value.gameStateParameterId, @@ -184,7 +191,7 @@ export class GameStateAPI implements IGameStateAPI { gameId: state.gameId, name: game ? game.name : state.name, localPath: state.path, - // isPublic: state.isPublic, + isPublic: state.isPublic, gameStateValues: response.gameStateValues.map((value) => ({ value: value.value, gameStateParameterId: value.gameStateParameterId, @@ -254,7 +261,7 @@ export class GameStateAPI implements IGameStateAPI { gameIconURL: state.gameIconUrl, name: state.name, sync: GameStateSync.NO, - isPublic: false, + isPublic: state.isPublic, localPath: state.localPath, archiveURL: state.archiveUrl, sizeInBytes: state.sizeInBytes, @@ -276,36 +283,37 @@ export class GameStateAPI implements IGameStateAPI { gameStateId: string; userId: string; }): Promise => { - console.log("addShare", share); + const formData = new FormData(); + formData.append( + "gameStateSharedData", + JSON.stringify({ + gameStateId: share.gameStateId, + shareWithId: share.userId, + }) + ); + + await this.fetcher.post(`/game-state-shares`, { + headers: {}, + body: formData, + }); }; getShares = async (gameStateId: string): Promise<{ items: Share[] }> => { - console.log("getShares", gameStateId); + const shares = await this.fetcher.get<{ + items: ShareFromServer[]; + }>(`/game-state-shares/${gameStateId}`); + return { - items: [ - { - id: "1", - gameStateId, - userId: "1", - username: "username", - }, - { - id: "2", - gameStateId, - userId: "2", - username: "username2", - }, - { - id: "3", - gameStateId, - userId: "3", - username: "username3", - }, - ], + items: shares.items.map((share) => ({ + id: share.id.toString(), + gameStateId, + userId: "share.userId", + username: share.username, + })), }; }; deleteShare = async (shareId: string): Promise => { - console.log("deleteShare", shareId); + await this.fetcher.delete(`/game-state-shares/${shareId}`); }; } diff --git a/src/client/api/UsersAPI.ts b/src/client/api/UsersAPI.ts new file mode 100644 index 0000000..89376c3 --- /dev/null +++ b/src/client/api/UsersAPI.ts @@ -0,0 +1,41 @@ +import { UserRole } from "@/types"; +import { Fetcher } from "./Fetcher"; +import { IUsersAPI, UserForAdmin } from "./interfaces/IUsersAPI"; +import { ResourceRequest, ResourceResponse } from "./interfaces/common"; + +export class UsersAPI implements IUsersAPI { + private readonly fetcher: Fetcher; + + constructor(fetcher: Fetcher) { + this.fetcher = fetcher; + } + + getUsers = async ( + query: ResourceRequest + ): Promise> => { + const users = await this.fetcher.get<{ + items: UserForAdmin[]; + totalCount: number; + }>( + `/users?searchQuery=${query.searchQuery}&pageSize=${query.pageSize}&pageNumber=${query.pageNumber}` + ); + + return { + items: users.items.map((user) => ({ + id: user.id.toString(), + username: user.username, + email: user.email, + role: UserRole.USER, + isBlocked: user.isBlocked, + })), + totalCount: users.totalCount, + }; + }; + + blockUser = async (userId: string): Promise => { + await this.fetcher.post(`/users/${userId}/block`); + }; + unblockUser = async (userId: string): Promise => { + await this.fetcher.post(`/users/${userId}/unblock`); + }; +} diff --git a/src/client/api/mocks/GameStateParameterTypesAPIMock.ts b/src/client/api/mocks/GameStateParameterTypesAPIMock.ts index 99639f8..ad5ec1a 100644 --- a/src/client/api/mocks/GameStateParameterTypesAPIMock.ts +++ b/src/client/api/mocks/GameStateParameterTypesAPIMock.ts @@ -9,10 +9,10 @@ export class GameStateParameterTypesAPIMock console.log("getParameterTypes", query); return { items: [ - { id: "string", type: "string" }, - { id: "number", type: "number" }, - { id: "seconds", type: "seconds" }, - { id: "boolean", type: "boolean" }, + { id: "1", type: "string" }, + { id: "2", type: "number" }, + { id: "3", type: "seconds" }, + { id: "4", type: "boolean" }, ], totalCount: 0, }; diff --git a/src/client/contexts/APIContext/APIContext.ts b/src/client/contexts/APIContext/APIContext.ts index 8f40135..072a4f1 100644 --- a/src/client/contexts/APIContext/APIContext.ts +++ b/src/client/contexts/APIContext/APIContext.ts @@ -19,6 +19,8 @@ import { IGameStateParameterTypeAPI } from "@/client/api/interfaces/IGameStatePa import { CommonParametersAPIMock } from "@/client/api/mocks/CommonParametersAPIMock"; import { GameStateParameterTypesAPIMock } from "@/client/api/mocks/GameStateParameterTypesAPIMock"; import { GameStateAPI } from "@/client/api/GameStateAPI"; +import { CommonParametersAPI } from "@/client/api/CommonParametersAPI"; +import { UsersAPI } from "@/client/api/UsersAPI"; interface APIContext { osAPI: IOSAPI; @@ -43,9 +45,11 @@ const gameAPI = API_BASE_URL ? new GameAPI(fetcher) : new GameAPIMock(); const gameStateAPI = API_BASE_URL ? new GameStateAPI(fetcher, osAPI, gameAPI) : new GameStateAPIMock(osAPI, gameAPI); -const usersAPI = new UsersAPIMock(); +const usersAPI = API_BASE_URL ? new UsersAPI(fetcher) : new UsersAPIMock(); -const commonParametersAPI = new CommonParametersAPIMock(); +const commonParametersAPI = API_BASE_URL + ? new CommonParametersAPI(fetcher) + : new CommonParametersAPIMock(); const parameterTypesAPI = new GameStateParameterTypesAPIMock(); export const api = { diff --git a/src/client/lib/components/SharesWidget/SharesWidget.tsx b/src/client/lib/components/SharesWidget/SharesWidget.tsx index fed2b2d..7aca6eb 100644 --- a/src/client/lib/components/SharesWidget/SharesWidget.tsx +++ b/src/client/lib/components/SharesWidget/SharesWidget.tsx @@ -48,7 +48,7 @@ export const SharesWidget = (props: SharesWidgetProps) => { }); return users.items.map((user) => ({ label: user.username, - value: user.username, + value: user.id, })); }, }, @@ -71,6 +71,15 @@ export const SharesWidget = (props: SharesWidgetProps) => { } }; + const onDelete = async (shareId: string) => { + try { + await gameStateAPI.deleteShare(shareId); + loadShares(); + } catch (error) { + notify.error(error); + } + }; + const [modal, openModal] = useModal({ children: (
@@ -84,10 +93,7 @@ export const SharesWidget = (props: SharesWidgetProps) => { renderElement={(share) => (
{share.username}
- gameStateAPI.deleteShare(share.id)} - > + onDelete(share.id)}> {t("delete-share")}
From 8af3ed7e9078d574cd87aac7bdb2f55999e2cb97 Mon Sep 17 00:00:00 2001 From: GravityTwoG Date: Sun, 31 Mar 2024 22:39:13 +0700 Subject: [PATCH 4/4] feat(gamestates): implement automatic synchronization --- package-lock.json | 104 +++++++++++++- package.json | 1 + src/@types/electron-api.d.ts | 11 +- src/Application.ts | 2 + src/backend/GameStateAPI.ts | 73 ---------- src/backend/StatesManager.ts | 48 +++---- src/backend/SyncManager.ts | 135 ++++++++++++++++-- src/backend/electron-api.ts | 55 ++++--- src/backend/fs/downloadToFolder.ts | 20 +++ src/backend/fs/extractZIP.ts | 9 ++ src/backend/fs/moveFolder.ts | 48 +++++++ src/backend/fs/utils.ts | 13 ++ src/backend/index.ts | 4 +- src/client/api/GameStateAPI.ts | 15 +- src/client/api/OSAPI.ts | 13 +- src/client/api/interfaces/IGameStateAPI.ts | 4 +- src/client/api/interfaces/IOSAPI.ts | 10 +- src/client/api/mocks/GameStateAPIMock.ts | 11 +- src/client/locales/en/pages/mySave.json | 1 + src/client/locales/ru/pages/mySave.json | 1 + .../pages/MySaves/MySave/MySavePage.tsx | 32 ++--- src/preload.ts | 10 +- 22 files changed, 424 insertions(+), 196 deletions(-) delete mode 100644 src/backend/GameStateAPI.ts create mode 100644 src/backend/fs/downloadToFolder.ts create mode 100644 src/backend/fs/extractZIP.ts create mode 100644 src/backend/fs/moveFolder.ts create mode 100644 src/backend/fs/utils.ts diff --git a/package-lock.json b/package-lock.json index 731a733..49c02e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "adm-zip": "^0.5.10", "clsx": "^2.1.0", + "electron-dl": "^3.5.2", "electron-squirrel-startup": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -6534,6 +6535,22 @@ "node": ">= 12.20.55" } }, + "node_modules/electron-dl": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-3.5.2.tgz", + "integrity": "sha512-i104cl+u8yJ0lhpRAtUWfeGuWuL1PL6TBiw2gLf0MMIBjfgE485Ags2mcySx4uWU9P9uj/vsD3jd7X+w1lzZxw==", + "dependencies": { + "ext-name": "^5.0.0", + "pupa": "^2.0.1", + "unused-filename": "^2.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/electron-packager": { "version": "17.1.2", "resolved": "https://registry.npmjs.org/electron-packager/-/electron-packager-17.1.2.tgz", @@ -7084,6 +7101,14 @@ "node": ">=6" } }, + "node_modules/escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "engines": { + "node": ">=8" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -7627,6 +7652,29 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "dependencies": { + "mime-db": "^1.28.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "dependencies": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -9502,7 +9550,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10867,7 +10914,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -11056,6 +11102,14 @@ "node": ">=10" } }, + "node_modules/modify-filename": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/modify-filename/-/modify-filename-1.1.0.tgz", + "integrity": "sha512-EickqnKq3kVVaZisYuCxhtKbZjInCuwgwZWyAmRIp1NTMhri7r3380/uqwrUHfaDiPzLVTuoNy4whX66bxPVog==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -14446,7 +14500,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -14847,6 +14900,17 @@ "node": ">=6" } }, + "node_modules/pupa": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "dependencies": { + "escape-goat": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -16425,6 +16489,28 @@ "node": ">= 10" } }, + "node_modules/sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "dependencies": { + "is-plain-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", + "dependencies": { + "sort-keys": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17362,6 +17448,18 @@ "node": ">= 0.8" } }, + "node_modules/unused-filename": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unused-filename/-/unused-filename-2.1.0.tgz", + "integrity": "sha512-BMiNwJbuWmqCpAM1FqxCTD7lXF97AvfQC8Kr/DIeA6VtvhJaMDupZ82+inbjl5yVP44PcxOuCSxye1QMS0wZyg==", + "dependencies": { + "modify-filename": "^1.1.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", diff --git a/package.json b/package.json index 4efcbe5..7db3d73 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dependencies": { "adm-zip": "^0.5.10", "clsx": "^2.1.0", + "electron-dl": "^3.5.2", "electron-squirrel-startup": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/@types/electron-api.d.ts b/src/@types/electron-api.d.ts index f4adf01..89e71cc 100644 --- a/src/@types/electron-api.d.ts +++ b/src/@types/electron-api.d.ts @@ -50,17 +50,14 @@ interface Window { }> >; - downloadSave: (archiveURL: string) => Promise>; - - downloadAndExtractSave: ( - archiveURL: string, - path: string - ) => Promise>; - onGetSyncedSaves: (callback: () => void) => void; sendSyncedSaves: ( args: import("../types").GameState[] ) => Promise>; + + downloadState: (gameState: import("../types").GameState) => Promise; + + downloadStateAs: (gameState: import("../types").GameState) => Promise; }; } diff --git a/src/Application.ts b/src/Application.ts index e09c7d0..6553de8 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -1,5 +1,6 @@ import path from "path"; import { BrowserWindow, Menu, Tray, app, nativeImage, Event } from "electron"; +import electronDl from "electron-dl"; import { setupIPC } from "./backend/electron-api"; import { SyncManager } from "./backend/SyncManager"; @@ -18,6 +19,7 @@ export class Application { init() { app.commandLine.appendSwitch("lang", "en-US"); this.registerProtocolClient(); + electronDl(); this.syncManager.init(() => { this.mainWindow?.webContents.send("getSyncedSaves"); diff --git a/src/backend/GameStateAPI.ts b/src/backend/GameStateAPI.ts deleted file mode 100644 index 229d60a..0000000 --- a/src/backend/GameStateAPI.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { session } from "electron"; -import { Game, GameState } from "@/types"; -import { StatesManager } from "./StatesManager"; - -export class GameStateAPI { - private statesManager: StatesManager; - - constructor(statesManager: StatesManager) { - this.statesManager = statesManager; - } - - uploadState = async (gameState: GameState) => { - const game = gameState.gameId - ? await this.getGame(gameState.gameId) - : undefined; - - const response = await this.statesManager.uploadSave( - { name: gameState.name, path: gameState.localPath }, - game - ); - - // upload buffer and gameStateValues - // await fetch(`${import.meta.env.VITE_API_BASE_URL}/game-saves`, { - // method: "POST", - // headers: { - // "Content-Type": "application/json", - // }, - // body: JSON.stringify({ - // buffer: response.buffer, - // gameStateValues: response.gameStateValues, - // }), - // }); - - return response; - }; - - downloadState = async (gameState: GameState) => { - await this.statesManager.downloadState(gameState.localPath); - }; - - private async getGame(gameId: string): Promise { - const cookies = await session.defaultSession.cookies.get({}); - - const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL}/games/${gameId}`, - { - headers: { - Cookie: cookies - .map((cookie) => `${cookie.name}=${cookie.value}`) - .join(";"), - }, - } - ); - - if (!response.ok) { - throw new Error(response.statusText); - } - - const game = await response.json(); - - return { - id: game.id.toString(), - name: game.name, - description: game.description, - paths: game.paths.map((path: { path: string }) => path.path), - extractionPipeline: game.extractionPipeline, - gameStateParameters: game.schema, - iconURL: `${import.meta.env.VITE_API_BASE_URL}/games/image/${ - game.imageId - }`, - }; - } -} diff --git a/src/backend/StatesManager.ts b/src/backend/StatesManager.ts index 316090a..49dc4c0 100644 --- a/src/backend/StatesManager.ts +++ b/src/backend/StatesManager.ts @@ -1,8 +1,13 @@ -import AdmZip from "adm-zip"; import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import AdmZip from "adm-zip"; -import { Game } from "@/types"; +import { Game, GameState } from "@/types"; import { ValueExtractor } from "./game-state-parameters/ValueExtractor"; +import { moveFolder } from "./fs/moveFolder"; +import { downloadToFolder } from "./fs/downloadToFolder"; +import { extractZIP } from "./fs/extractZIP"; export class StatesManager { private readonly valueExtractor: ValueExtractor; @@ -11,7 +16,7 @@ export class StatesManager { this.valueExtractor = valueExtractor; } - async uploadSave(folder: { path: string; name: string }, game?: Game) { + async uploadState(folder: { path: string; name: string }, game?: Game) { const zip = new AdmZip(); const isDirectory = (await fs.lstat(folder.path)).isDirectory(); @@ -24,7 +29,7 @@ export class StatesManager { // await zip.writeZipPromise(`${path}.zip`); const gameStateValues = game ? await this.valueExtractor.extract(folder, game) - : { fields: [] }; + : []; const buffer = zip.toBuffer(); return { @@ -33,34 +38,17 @@ export class StatesManager { }; } - async downloadState(archiveURL: string) { - // TODO - console.log("Downloading state", archiveURL); - return { path: "" }; - } - - async downloadAndExtractSave(archiveURL: string, path: string) { - // TODO - const state = await this.downloadState(archiveURL); - // TODO - this.extractZIP(state.path); - - console.log("Extracted to", path); - - return state; - } - - private extractZIP(filePath: string) { - const zip = new AdmZip(filePath); + async downloadState(gameState: GameState) { + const tempPath = os.tmpdir(); + const archivePath = path.join(tempPath, "cloud-saves"); + const filename = `${gameState.name}-archive.zip`; + const filePath = path.join(archivePath, filename); - const zipEntries = zip.getEntries(); - - for (const entry of zipEntries) { - console.log(entry.entryName); - } + await downloadToFolder(gameState.archiveURL, archivePath, filename); - zip.extractAllTo(filePath.replace(".zip", ".extracted")); + const extractedFolderPath = await extractZIP(filePath); - // TODO + // move extracted folder to game folder + await moveFolder(extractedFolderPath, gameState.localPath); } } diff --git a/src/backend/SyncManager.ts b/src/backend/SyncManager.ts index 1129d27..fcea39c 100644 --- a/src/backend/SyncManager.ts +++ b/src/backend/SyncManager.ts @@ -1,7 +1,7 @@ -import { GameState, GameStateSync } from "@/types"; -import { ipcMain } from "electron"; +import { Game, GameState, GameStateSync } from "@/types"; +import { ipcMain, session } from "electron"; import { getModifiedAtMs } from "./fs/getModifiedAtMs"; -import { GameStateAPI } from "./GameStateAPI"; +import { StatesManager } from "./StatesManager"; const SECOND = 1000; const MINUTE = 60 * SECOND; @@ -9,12 +9,36 @@ const HOUR = 60 * MINUTE; const MIN_SYNC_PERIOD_MS = 1000 * 60; +type GameFromServer = { + id: number; + name: string; + description: string; + paths: { id: string; path: string }[]; + extractionPipeline: { + inputFilename: string; + type: "sav-to-json"; + outputFilename: string; + }[]; + schema: { + filename: string; + gameStateParameters: { + id: number; + key: string; + type: string; + label: string; + description: string; + commonParameterId: number; + }[]; + }; + imageUrl: string; +}; + export class SyncManager { private intervalId: NodeJS.Timeout | null = null; - private gameStateAPI: GameStateAPI; + private statesManager: StatesManager; - constructor(gameStateAPI: GameStateAPI) { - this.gameStateAPI = gameStateAPI; + constructor(statesManager: StatesManager) { + this.statesManager = statesManager; } init(onGetSyncedStates: () => void) { @@ -55,8 +79,7 @@ export class SyncManager { if (currentTimeMs - lastUploadMs > periodMs && periodMs > 0) { console.log("Uploading state", gameState.localPath); - - await this.gameStateAPI.uploadState(gameState); + await this.uploadState(gameState); } } catch (error) { if (error instanceof Error) { @@ -68,6 +91,100 @@ export class SyncManager { } } + private async uploadState(gameState: GameState) { + const game = gameState.gameId + ? await this.getGame(gameState.gameId) + : undefined; + + const response = await this.statesManager.uploadState( + { name: gameState.name, path: gameState.localPath }, + game + ); + + const formData = new FormData(); + formData.append("archive", new Blob([response.buffer])); + formData.append( + "gameStateData", + JSON.stringify({ + gameId: gameState.gameId, + name: game ? game.name : gameState.name, + localPath: gameState.localPath, + isPublic: false, + gameStateValues: response.gameStateValues.map((value) => ({ + value: value.value, + gameStateParameterId: value.gameStateParameterId, + })), + }) + ); + + const response2 = await fetch( + `${import.meta.env.VITE_API_BASE_URL}/game-saves/${gameState.id}`, + { + method: "PATCH", + headers: { + Cookie: await this.buildCookieHeader(), + }, + body: formData, + } + ); + + console.log(response2.status); + console.log(response2.statusText); + } + + private async buildCookieHeader() { + const cookies = await session.defaultSession.cookies.get({}); + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join(";"); + } + + private async getGame(gameId: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_API_BASE_URL}/games/${gameId}`, + { + headers: { + Cookie: await this.buildCookieHeader(), + }, + } + ); + + if (!response.ok) { + throw new Error(response.statusText); + } + + const game = (await response.json()) as GameFromServer; + + return { + id: game.id.toString(), + name: game.name, + description: game.description, + paths: game.paths, + extractionPipeline: game.extractionPipeline, + gameStateParameters: { + filename: game.schema.filename, + parameters: game.schema.gameStateParameters.map((field) => ({ + id: field.id.toString(), + key: field.key, + type: { + type: field.type, + id: field.type, + }, + commonParameter: { + id: field.commonParameterId.toString(), + type: { + type: field.type, + id: field.type, + }, + label: "", + description: "", + }, + label: field.label, + description: field.description, + })), + }, + iconURL: game.imageUrl, + }; + } + private async downloadSynced(gameStates: GameState[]) { for (const gameState of gameStates) { const lastUploadMs = new Date(gameState.uploadedAt).getTime(); @@ -76,7 +193,7 @@ export class SyncManager { try { if (lastUploadMs > modifiedAtMs) { console.log("Downloading state", gameState.localPath); - await this.gameStateAPI.downloadState(gameState); + await this.statesManager.downloadState(gameState); } } catch (error) { if (error instanceof Error) { diff --git a/src/backend/electron-api.ts b/src/backend/electron-api.ts index d79794a..e80dc00 100644 --- a/src/backend/electron-api.ts +++ b/src/backend/electron-api.ts @@ -1,9 +1,11 @@ -import { dialog, ipcMain } from "electron"; -import { Game, GamePath } from "@/types"; +import { BrowserWindow, dialog, ipcMain } from "electron"; +import electronDl from "electron-dl"; +import { Game, GamePath, GameState } from "@/types"; import { getFolderInfo } from "./fs/getFolderInfo"; import { getStatePaths } from "./fs/getStatePaths"; import { statesManager } from "."; +import { downloadToFolder } from "./fs/downloadToFolder"; export function setupIPC() { ipcMain.handle("showFolderDialog", async () => { @@ -49,7 +51,7 @@ export function setupIPC() { game: Game ) => { try { - const data = await statesManager.uploadSave(folder, game); + const data = await statesManager.uploadState(folder, game); return { data }; } catch (error) { return { data: null, error: (error as Error)?.toString() }; @@ -57,24 +59,45 @@ export function setupIPC() { } ); - ipcMain.handle("downloadSave", async (_, archiveURL: string) => { + ipcMain.handle("downloadState", async (_, gameState: GameState) => { try { - await statesManager.downloadState(archiveURL); - return { data: null }; + await statesManager.downloadState(gameState); } catch (error) { - return { data: null, error: (error as Error).toString() }; + if (error instanceof electronDl.CancelError) { + console.info("item.cancel() was called"); + } else { + console.error(error); + } } }); - ipcMain.handle( - "downloadAndExtractSave", - async (_, archiveURL: string, path: string) => { - try { - await statesManager.downloadAndExtractSave(archiveURL, path); - return { data: null }; - } catch (error) { - return { data: null, error: (error as Error).toString() }; + ipcMain.handle("downloadStateAs", async (_, gameState: GameState) => { + try { + const win = BrowserWindow.getFocusedWindow(); + if (!win) { + return; + } + + const customPath = await dialog.showOpenDialog({ + title: "Download", + defaultPath: gameState.localPath, + properties: ["openDirectory"], + }); + if (customPath.canceled || !customPath.filePaths[0]) { + return; + } + + await downloadToFolder( + gameState.archiveURL, + customPath.filePaths[0], + `${gameState.name}-archive.zip` + ); + } catch (error) { + if (error instanceof electronDl.CancelError) { + console.info("item.cancel() was called"); + } else { + console.error(error); } } - ); + }); } diff --git a/src/backend/fs/downloadToFolder.ts b/src/backend/fs/downloadToFolder.ts new file mode 100644 index 0000000..71b487a --- /dev/null +++ b/src/backend/fs/downloadToFolder.ts @@ -0,0 +1,20 @@ +import { BrowserWindow } from "electron"; +import electronDl from "electron-dl"; + +export async function downloadToFolder( + sourceURL: string, + targetFolder: string, + targetFilename: string +): Promise { + const win = BrowserWindow.getAllWindows(); + if (!win.length) { + return Promise.reject("No focused window"); + } + + await electronDl.download(win[0], sourceURL, { + saveAs: false, + directory: targetFolder, + filename: targetFilename, + overwrite: true, + }); +} diff --git a/src/backend/fs/extractZIP.ts b/src/backend/fs/extractZIP.ts new file mode 100644 index 0000000..b0e7d1d --- /dev/null +++ b/src/backend/fs/extractZIP.ts @@ -0,0 +1,9 @@ +import AdmZip from "adm-zip"; + +export function extractZIP(filePath: string) { + const zip = new AdmZip(filePath); + const extractedFolderName = filePath.replace(".zip", ".extracted"); + zip.extractAllTo(extractedFolderName, true); + + return extractedFolderName; +} diff --git a/src/backend/fs/moveFolder.ts b/src/backend/fs/moveFolder.ts new file mode 100644 index 0000000..5aea8c7 --- /dev/null +++ b/src/backend/fs/moveFolder.ts @@ -0,0 +1,48 @@ +import fs from "fs/promises"; +import path from "path"; +import { isSystemError } from "./utils"; + +async function tryToMkdir(path: string): Promise { + try { + await fs.mkdir(path); + } catch (error) { + if (isSystemError(error) && error.code !== "EEXIST") { + throw error; + } + } +} + +async function moveFolderRecursive( + sourceDir: string, + targetDir: string +): Promise { + const files = await fs.readdir(sourceDir); + + await Promise.all( + files.map(async (file) => { + const oldPath = path.join(sourceDir, file); + const newPath = path.join(targetDir, file); + const stat = await fs.lstat(oldPath); + + if (stat.isDirectory()) { + await tryToMkdir(newPath); + await moveFolderRecursive(oldPath, targetDir); + } else if (stat.isFile()) { + await fs.rename(oldPath, newPath); + } + }) + ); +} + +export async function moveFolder( + sourceDir: string, + targetDir: string +): Promise { + const sourceStat = await fs.lstat(sourceDir); + + if (sourceStat.isDirectory()) { + await tryToMkdir(targetDir); + } + + await moveFolderRecursive(sourceDir, targetDir); +} diff --git a/src/backend/fs/utils.ts b/src/backend/fs/utils.ts new file mode 100644 index 0000000..54ba10b --- /dev/null +++ b/src/backend/fs/utils.ts @@ -0,0 +1,13 @@ +import { getSystemErrorMap } from "node:util"; + +const systemErrorMap = getSystemErrorMap(); +export function isSystemError(error: unknown): error is NodeJS.ErrnoException { + let errno: unknown; + try { + errno = (error as { errno?: number })?.errno; + } catch { + // In case `errno` is a getter that throws: + return false; + } + return typeof errno === "number" && systemErrorMap.has(errno); +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 2b36c0f..98e14d6 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,5 +1,3 @@ -import { GameStateAPI } from "./GameStateAPI"; - import { StatesManager } from "./StatesManager"; import { SyncManager } from "./SyncManager"; @@ -15,4 +13,4 @@ const converters = { const valueExtractor = new ValueExtractor(converters); export const statesManager = new StatesManager(valueExtractor); -export const syncManager = new SyncManager(new GameStateAPI(statesManager)); +export const syncManager = new SyncManager(statesManager); diff --git a/src/client/api/GameStateAPI.ts b/src/client/api/GameStateAPI.ts index d0629e3..375ec6b 100644 --- a/src/client/api/GameStateAPI.ts +++ b/src/client/api/GameStateAPI.ts @@ -239,15 +239,12 @@ export class GameStateAPI implements IGameStateAPI { } } - downloadState = async (path: string) => { - await this.osAPI.downloadState(path); + downloadState = async (gameState: GameState) => { + await this.osAPI.downloadState(gameState); }; - downloadAndExtractState = async ( - archiveURL: string, - path: string - ): Promise => { - await this.osAPI.downloadAndExtractState(archiveURL, path); + downloadStateAs = async (gameState: GameState) => { + await this.osAPI.downloadStateAs(gameState); }; deleteState = async (gameStateId: string): Promise => { @@ -300,11 +297,11 @@ export class GameStateAPI implements IGameStateAPI { getShares = async (gameStateId: string): Promise<{ items: Share[] }> => { const shares = await this.fetcher.get<{ - items: ShareFromServer[]; + gameStateShares: ShareFromServer[]; }>(`/game-state-shares/${gameStateId}`); return { - items: shares.items.map((share) => ({ + items: shares.gameStateShares.map((share) => ({ id: share.id.toString(), gameStateId, userId: "share.userId", diff --git a/src/client/api/OSAPI.ts b/src/client/api/OSAPI.ts index 3bca961..107f698 100644 --- a/src/client/api/OSAPI.ts +++ b/src/client/api/OSAPI.ts @@ -1,4 +1,4 @@ -import { Game, GamePath } from "@/types"; +import { Game, GamePath, GameState } from "@/types"; import { IOSAPI } from "./interfaces/IOSAPI"; import { ApiError } from "./ApiError"; @@ -53,14 +53,11 @@ export class OSAPI implements IOSAPI { return response.data; }; - downloadState = async (archiveURL: string): Promise => { - await window.electronAPI.downloadSave(archiveURL); + downloadState = async (gameState: GameState): Promise => { + await window.electronAPI.downloadState(gameState); }; - downloadAndExtractState = async ( - archiveURL: string, - path: string - ): Promise => { - await window.electronAPI.downloadAndExtractSave(archiveURL, path); + downloadStateAs = async (gameState: GameState): Promise => { + await window.electronAPI.downloadStateAs(gameState); }; } diff --git a/src/client/api/interfaces/IGameStateAPI.ts b/src/client/api/interfaces/IGameStateAPI.ts index 990566a..31f9efb 100644 --- a/src/client/api/interfaces/IGameStateAPI.ts +++ b/src/client/api/interfaces/IGameStateAPI.ts @@ -24,9 +24,9 @@ export interface IGameStateAPI { sync: GameStateSync; }): Promise; - downloadState(archiveURL: string): Promise; + downloadState(gameState: GameState): Promise; - downloadAndExtractState(archiveURL: string, path: string): Promise; + downloadStateAs(gameState: GameState): Promise; deleteState(gameStateId: string): Promise; diff --git a/src/client/api/interfaces/IOSAPI.ts b/src/client/api/interfaces/IOSAPI.ts index 544b6c5..3e7af7a 100644 --- a/src/client/api/interfaces/IOSAPI.ts +++ b/src/client/api/interfaces/IOSAPI.ts @@ -1,4 +1,4 @@ -import { Game, GamePath } from "@/types"; +import { Game, GamePath, GameState } from "@/types"; export interface IOSAPI { getSavePaths: (paths: GamePath[]) => Promise>; @@ -21,9 +21,9 @@ export interface IOSAPI { }[]; }>; - // Just download - downloadState(archiveURL: string): Promise; - // Download and extract to states folder of the game - downloadAndExtractState(archiveURL: string, path: string): Promise; + downloadState(gameState: GameState): Promise; + + // Just download + downloadStateAs(gameState: GameState): Promise; } diff --git a/src/client/api/mocks/GameStateAPIMock.ts b/src/client/api/mocks/GameStateAPIMock.ts index bc79d06..dea96aa 100644 --- a/src/client/api/mocks/GameStateAPIMock.ts +++ b/src/client/api/mocks/GameStateAPIMock.ts @@ -242,15 +242,12 @@ export class GameStateAPIMock implements IGameStateAPI { } } - downloadState = async (path: string) => { - await this.osAPI.downloadState(path); + downloadState = async (gameState: GameState) => { + await this.osAPI.downloadState(gameState); }; - downloadAndExtractState = async ( - archiveURL: string, - path: string - ): Promise => { - await this.osAPI.downloadAndExtractState(archiveURL, path); + downloadStateAs = async (gameState: GameState) => { + await this.osAPI.downloadStateAs(gameState); }; deleteState = async (gameStateId: string): Promise => { diff --git a/src/client/locales/en/pages/mySave.json b/src/client/locales/en/pages/mySave.json index 552f8b2..7a93398 100644 --- a/src/client/locales/en/pages/mySave.json +++ b/src/client/locales/en/pages/mySave.json @@ -26,6 +26,7 @@ "about": "About", "uploaded-at": "Uploaded at:", "download": "Download", + "download-as": "Download as", "size": "Size", "sync": "Sync" } diff --git a/src/client/locales/ru/pages/mySave.json b/src/client/locales/ru/pages/mySave.json index ccf439a..3f51e5c 100644 --- a/src/client/locales/ru/pages/mySave.json +++ b/src/client/locales/ru/pages/mySave.json @@ -26,6 +26,7 @@ "about": "Информация", "uploaded-at": "Загружено:", "download": "Скачать", + "download-as": "Скачать как", "size": "Размер", "sync": "Синхронизация" } diff --git a/src/client/pages/MySaves/MySave/MySavePage.tsx b/src/client/pages/MySaves/MySave/MySavePage.tsx index d435cd8..85adbbe 100644 --- a/src/client/pages/MySaves/MySave/MySavePage.tsx +++ b/src/client/pages/MySaves/MySave/MySavePage.tsx @@ -101,14 +101,18 @@ export const MySavePage = () => { } }; - const downloadSave = async (state: { - url: string; - id: string; - size: number; - createdAt: string; - }) => { + const downloadState = async () => { try { - const response = await gameStateAPI.downloadState(state.url); + const response = await gameStateAPI.downloadState(gameState); + console.log(response); + } catch (error) { + notify.error(error); + } + }; + + const downloadStateAs = async () => { + try { + const response = await gameStateAPI.downloadStateAs(gameState); console.log(response); } catch (error) { notify.error(error); @@ -236,18 +240,8 @@ export const MySavePage = () => {
- + +
diff --git a/src/preload.ts b/src/preload.ts index c0aea30..9b0f43c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -18,16 +18,16 @@ const electronApi: Window["electronAPI"] = { uploadSave: (folder, game) => ipcRenderer.invoke("uploadSave", folder, game), - downloadSave: (archiveURL) => ipcRenderer.invoke("downloadSave", archiveURL), - - downloadAndExtractSave: (archiveURL, path) => - ipcRenderer.invoke("downloadAndExtractSave", archiveURL, path), - onGetSyncedSaves: (callback) => { ipcRenderer.on("getSyncedSaves", callback); }, sendSyncedSaves: (args) => ipcRenderer.invoke("sendSyncedSaves", args), + + downloadState: (gameState) => ipcRenderer.invoke("downloadState", gameState), + + downloadStateAs: (gameState) => + ipcRenderer.invoke("downloadStateAs", gameState), }; contextBridge.exposeInMainWorld("electronAPI", electronApi);