diff --git a/src/@types/electron-api.d.ts b/src/@types/electron-api.d.ts index 89e71cc..08d9450 100644 --- a/src/@types/electron-api.d.ts +++ b/src/@types/electron-api.d.ts @@ -26,7 +26,7 @@ interface Window { showFolderDialog: () => Promise>; - getSavePaths: ( + getStatePaths: ( paths: import("../types").GamePath[] ) => Promise>; @@ -34,30 +34,37 @@ interface Window { folderPath: string ) => Promise>; - uploadSave: ( - folder: { - path: string; - name: string; - }, - game: import("../types").Game - ) => Promise< + onGetSyncedStates: (callback: () => void) => void; + + sendSyncedStates: ( + args: import("../types").GameState[] + ) => Promise>; + + uploadState: (folder: { + gameId?: string; + localPath: string; + name: string; + isPublic: boolean; + }) => Promise< ElectronApiResponse<{ buffer: Buffer; - gameStateValues: { - gameStateParameterId: string; - value: string; - }[]; + gameStateValues: { value: string; gameStateParameterId: string }[]; }> >; - onGetSyncedSaves: (callback: () => void) => void; + reuploadState: (state: import("../types").GameState) => Promise< + ElectronApiResponse<{ + buffer: Buffer; + gameStateValues: { value: string; gameStateParameterId: string }[]; + }> + >; - sendSyncedSaves: ( - args: import("../types").GameState[] + downloadState: ( + gameState: import("../types").GameState ) => Promise>; - downloadState: (gameState: import("../types").GameState) => Promise; - - downloadStateAs: (gameState: import("../types").GameState) => Promise; + downloadStateAs: ( + gameState: import("../types").GameState + ) => Promise>; }; } diff --git a/src/Application.ts b/src/Application.ts index 6553de8..7891cf6 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -1,10 +1,17 @@ import path from "path"; -import { BrowserWindow, Menu, Tray, app, nativeImage, Event } from "electron"; +import { + BrowserWindow, + Menu, + Tray, + app, + nativeImage, + Event, + ipcMain, +} from "electron"; import electronDl from "electron-dl"; -import { setupIPC } from "./backend/electron-api"; import { SyncManager } from "./backend/SyncManager"; -import { syncManager } from "./backend"; +import { syncManager, electronAPI } from "./backend"; const protocolName = "cloud-saves"; const clientProtocol = `${protocolName}://`; @@ -22,7 +29,7 @@ export class Application { electronDl(); this.syncManager.init(() => { - this.mainWindow?.webContents.send("getSyncedSaves"); + this.mainWindow?.webContents.send("getSyncedStates"); }); const gotTheLock = app.requestSingleInstanceLock(); @@ -67,15 +74,14 @@ export class Application { app.on("ready", () => { this.mainWindow = this.createWindow(); this.createTray(); - - setupIPC(); + this.setupIPC(); app.on("activate", () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { this.mainWindow = this.createWindow(); - setupIPC(); + this.setupIPC(); } }); }); @@ -95,6 +101,29 @@ export class Application { }); } + private setupIPC() { + function registerHandler( + name: string, + handler: (...args: T) => R + ) { + ipcMain.handle(name, (_, ...args: unknown[]) => handler(...(args as T))); + } + + registerHandler("showFolderDialog", electronAPI.showFolderDialog); + + registerHandler("getStatePaths", electronAPI.getStatePaths); + + registerHandler("getFolderInfo", electronAPI.getFolderInfo); + + registerHandler("uploadState", electronAPI.uploadState); + + registerHandler("reuploadState", electronAPI.reuploadState); + + registerHandler("downloadState", electronAPI.downloadState); + + registerHandler("downloadStateAs", electronAPI.downloadStateAs); + } + private registerProtocolClient() { if (process.defaultApp) { if (process.argv.length >= 2) { diff --git a/src/backend/StatesManager.ts b/src/backend/StatesManager.ts index 49dc4c0..9e58a63 100644 --- a/src/backend/StatesManager.ts +++ b/src/backend/StatesManager.ts @@ -2,8 +2,10 @@ import fs from "fs/promises"; import path from "path"; import os from "os"; import AdmZip from "adm-zip"; +import { session } from "electron"; import { Game, GameState } from "@/types"; +import { GameAPI, GameFromServer } from "@/client/api/GameAPI"; import { ValueExtractor } from "./game-state-parameters/ValueExtractor"; import { moveFolder } from "./fs/moveFolder"; import { downloadToFolder } from "./fs/downloadToFolder"; @@ -16,39 +18,145 @@ export class StatesManager { this.valueExtractor = valueExtractor; } - async uploadState(folder: { path: string; name: string }, game?: Game) { + async uploadState(folder: { + gameId?: string; + localPath: string; + name: string; + isPublic: boolean; + }) { + const gameStateData = await this.getState(folder); + const formData = this.mapToGameStateData( + { + ...folder, + gameId: folder.gameId || "", + }, + gameStateData + ); + + await fetch(`${import.meta.env.VITE_API_BASE_URL}/game-saves`, { + method: "POST", + headers: { + Cookie: await this.buildCookieHeader(), + }, + body: formData, + }); + + return gameStateData; + } + + async reuploadState(gameState: GameState) { + const gameStateData = await this.getState(gameState); + const formData = this.mapToGameStateData(gameState, gameStateData); + + 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); + return gameStateData; + } + + 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); + + await downloadToFolder(gameState.archiveURL, archivePath, filename); + + const extractedFolderPath = await extractZIP(filePath); + + // move extracted folder to game states folder + await moveFolder(extractedFolderPath, gameState.localPath); + } + + private mapToGameStateData = ( + gameState: { + gameId: string; + localPath: string; + name: string; + isPublic: boolean; + }, + response: { + buffer: Buffer; + gameStateValues: { value: string; gameStateParameterId: string }[]; + } + ) => { + const formData = new FormData(); + formData.append("archive", new Blob([response.buffer])); + formData.append( + "gameStateData", + JSON.stringify({ + gameId: gameState.gameId, + name: gameState.name, + localPath: gameState.localPath, + isPublic: gameState.isPublic, + gameStateValues: response.gameStateValues.map((value) => ({ + value: value.value, + gameStateParameterId: value.gameStateParameterId, + })), + }) + ); + + return formData; + }; + + // returns gameStateValues and buffer with gameState archive + private async getState(folder: { + gameId?: string; + localPath: string; + name: string; + }) { const zip = new AdmZip(); - const isDirectory = (await fs.lstat(folder.path)).isDirectory(); + const isDirectory = (await fs.lstat(folder.localPath)).isDirectory(); if (isDirectory) { - await zip.addLocalFolderPromise(folder.path, {}); + await zip.addLocalFolderPromise(folder.localPath, {}); } else { - zip.addLocalFile(folder.path); + zip.addLocalFile(folder.localPath); } - // await zip.writeZipPromise(`${path}.zip`); + + const game = folder.gameId ? await this.getGame(folder.gameId) : undefined; const gameStateValues = game - ? await this.valueExtractor.extract(folder, game) + ? await this.valueExtractor.extract(folder.localPath, game) : []; - const buffer = zip.toBuffer(); return { - buffer, + buffer: zip.toBuffer(), gameStateValues, }; } - 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); + private async getGame(gameId: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_API_BASE_URL}/games/${gameId}`, + { + headers: { + Cookie: await this.buildCookieHeader(), + }, + } + ); - await downloadToFolder(gameState.archiveURL, archivePath, filename); + if (!response.ok) { + throw new Error(response.statusText); + } - const extractedFolderPath = await extractZIP(filePath); + const game = (await response.json()) as GameFromServer; - // move extracted folder to game folder - await moveFolder(extractedFolderPath, gameState.localPath); + return GameAPI.mapGameFromServer(game); + } + + private async buildCookieHeader() { + const cookies = await session.defaultSession.cookies.get({}); + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join(";"); } } diff --git a/src/backend/SyncManager.ts b/src/backend/SyncManager.ts index fcea39c..6e97074 100644 --- a/src/backend/SyncManager.ts +++ b/src/backend/SyncManager.ts @@ -1,5 +1,6 @@ -import { Game, GameState, GameStateSync } from "@/types"; -import { ipcMain, session } from "electron"; +import { ipcMain } from "electron"; + +import { GameState, GameStateSync } from "@/types"; import { getModifiedAtMs } from "./fs/getModifiedAtMs"; import { StatesManager } from "./StatesManager"; @@ -9,30 +10,6 @@ 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 statesManager: StatesManager; @@ -47,7 +24,7 @@ export class SyncManager { }, MIN_SYNC_PERIOD_MS); let isSyncing = false; - ipcMain.handle("sendSyncedSaves", async (_, gameStates: GameState[]) => { + ipcMain.handle("sendSyncedStates", async (_, gameStates: GameState[]) => { if (isSyncing) { return; } @@ -65,7 +42,7 @@ export class SyncManager { this.intervalId = null; } - ipcMain.removeAllListeners("sendSyncedSaves"); + ipcMain.removeAllListeners("sendSyncedStates"); } private async uploadSynced(gameStates: GameState[]) { @@ -79,7 +56,7 @@ export class SyncManager { if (currentTimeMs - lastUploadMs > periodMs && periodMs > 0) { console.log("Uploading state", gameState.localPath); - await this.uploadState(gameState); + await this.statesManager.reuploadState(gameState); } } catch (error) { if (error instanceof Error) { @@ -91,100 +68,6 @@ 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(); diff --git a/src/backend/electron-api.ts b/src/backend/electron-api.ts index e80dc00..e22d826 100644 --- a/src/backend/electron-api.ts +++ b/src/backend/electron-api.ts @@ -1,14 +1,17 @@ -import { BrowserWindow, dialog, ipcMain } from "electron"; +import { BrowserWindow, dialog } from "electron"; import electronDl from "electron-dl"; -import { Game, GamePath, GameState } from "@/types"; +import { GamePath, GameState } from "@/types"; import { getFolderInfo } from "./fs/getFolderInfo"; import { getStatePaths } from "./fs/getStatePaths"; -import { statesManager } from "."; import { downloadToFolder } from "./fs/downloadToFolder"; +import { statesManager } from "."; -export function setupIPC() { - ipcMain.handle("showFolderDialog", async () => { +export const electronAPI: Omit< + Window["electronAPI"], + "sendSyncedStates" | "onGetSyncedStates" | "onDeepLink" +> = { + showFolderDialog: async () => { try { const obj = await dialog.showOpenDialog({ properties: ["openDirectory"], @@ -22,60 +25,69 @@ export function setupIPC() { } catch (error) { return { data: null, error: (error as Error).toString() }; } - }); + }, + + getFolderInfo: async (folderPath: string) => { + try { + const info = getFolderInfo(folderPath); + + return { data: info }; + } catch (error) { + return { data: null, error: (error as Error).toString() }; + } + }, - ipcMain.handle("getSavePaths", async (_, paths: GamePath[]) => { + getStatePaths: async (paths: GamePath[]) => { try { const filteredPaths = await getStatePaths(paths); return { data: filteredPaths }; } catch (error) { return { data: null, error: (error as Error).toString() }; } - }); + }, - ipcMain.handle("getFolderInfo", async (_, folderPath: string) => { + uploadState: async (state: { + gameId?: string; + localPath: string; + name: string; + isPublic: boolean; + }) => { try { - const info = getFolderInfo(folderPath); - - return { data: info }; + const data = await statesManager.uploadState(state); + return { data }; } catch (error) { - return { data: null, error: (error as Error).toString() }; + return { data: null, error: (error as Error)?.toString() }; } - }); + }, - ipcMain.handle( - "uploadSave", - async ( - _, - folder: { gameId: string; path: string; name: string }, - game: Game - ) => { - try { - const data = await statesManager.uploadState(folder, game); - return { data }; - } catch (error) { - return { data: null, error: (error as Error)?.toString() }; - } + reuploadState: async (state: GameState) => { + try { + const data = await statesManager.reuploadState(state); + return { data }; + } catch (error) { + return { data: null, error: (error as Error)?.toString() }; } - ); + }, - ipcMain.handle("downloadState", async (_, gameState: GameState) => { + downloadState: async (gameState: GameState) => { try { await statesManager.downloadState(gameState); + return { data: null }; } catch (error) { if (error instanceof electronDl.CancelError) { console.info("item.cancel() was called"); } else { console.error(error); } + return { data: null, error: (error as Error)?.toString() }; } - }); + }, - ipcMain.handle("downloadStateAs", async (_, gameState: GameState) => { + downloadStateAs: async (gameState: GameState) => { try { const win = BrowserWindow.getFocusedWindow(); if (!win) { - return; + return { data: null }; } const customPath = await dialog.showOpenDialog({ @@ -84,7 +96,7 @@ export function setupIPC() { properties: ["openDirectory"], }); if (customPath.canceled || !customPath.filePaths[0]) { - return; + return { data: null }; } await downloadToFolder( @@ -92,12 +104,16 @@ export function setupIPC() { customPath.filePaths[0], `${gameState.name}-archive.zip` ); + + return { data: null }; } catch (error) { if (error instanceof electronDl.CancelError) { console.info("item.cancel() was called"); } else { console.error(error); } + + return { data: null, error: (error as Error)?.toString() }; } - }); -} + }, +}; diff --git a/src/backend/fs/getFolderInfo.ts b/src/backend/fs/getFolderInfo.ts index cf45c6a..9a02729 100644 --- a/src/backend/fs/getFolderInfo.ts +++ b/src/backend/fs/getFolderInfo.ts @@ -1,7 +1,10 @@ import fs from "fs"; import path from "path"; -export function getFolderInfo(folderPath: string) { +export function getFolderInfo(folderPath: string): { + folder: string; + files: FileInfo[]; +} { const files = fs .readdirSync(folderPath, { withFileTypes: true }) .map((dirent) => { @@ -12,7 +15,7 @@ export function getFolderInfo(folderPath: string) { path: absolutefilepath, size: stats.size, mtime: stats.mtime, - type: dirent.isDirectory() ? "folder" : "file", + type: dirent.isDirectory() ? "folder" : ("file" as "file" | "folder"), }; }); diff --git a/src/backend/fs/utils.ts b/src/backend/fs/isSystemError.ts similarity index 100% rename from src/backend/fs/utils.ts rename to src/backend/fs/isSystemError.ts diff --git a/src/backend/fs/moveFolder.ts b/src/backend/fs/moveFolder.ts index 5aea8c7..dd46875 100644 --- a/src/backend/fs/moveFolder.ts +++ b/src/backend/fs/moveFolder.ts @@ -1,6 +1,6 @@ import fs from "fs/promises"; import path from "path"; -import { isSystemError } from "./utils"; +import { isSystemError } from "./isSystemError"; async function tryToMkdir(path: string): Promise { try { diff --git a/src/backend/game-state-parameters/ValueExtractor.ts b/src/backend/game-state-parameters/ValueExtractor.ts index 1693f6b..393080f 100644 --- a/src/backend/game-state-parameters/ValueExtractor.ts +++ b/src/backend/game-state-parameters/ValueExtractor.ts @@ -12,24 +12,24 @@ export class ValueExtractor { this.converters = converters; } - async extract(folder: { path: string; name: string }, game: Game) { + async extract(filePath: string, game: Game) { const createdFiles: string[] = []; for (const pipelineItem of game.extractionPipeline) { if (this.converters[pipelineItem.type]) { await this.converters["sav-to-json"].convert( - folder.path, + filePath, pipelineItem.inputFilename, pipelineItem.outputFilename ); - createdFiles.push(path.join(folder.path, pipelineItem.outputFilename)); + createdFiles.push(path.join(filePath, pipelineItem.outputFilename)); } } const gameStateParameters = game.gameStateParameters; const json = await fs.readFile( - path.join(folder.path, gameStateParameters.filename), + path.join(filePath, gameStateParameters.filename), { encoding: "utf-8", } diff --git a/src/backend/index.ts b/src/backend/index.ts index 98e14d6..86850c9 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -5,6 +5,7 @@ import { ValueExtractor } from "./game-state-parameters/ValueExtractor"; import { SAVConverter } from "./game-state-parameters/converters/SAVConverter"; import { ColonTextConverter } from "./game-state-parameters/converters/ColonTextConverter"; +export { electronAPI } from "./electron-api"; const converters = { "sav-to-json": new SAVConverter(), diff --git a/src/client/DeepLinkHandler.tsx b/src/client/DeepLinkHandler.tsx index 3ad89d5..ae7b433 100644 --- a/src/client/DeepLinkHandler.tsx +++ b/src/client/DeepLinkHandler.tsx @@ -1,12 +1,15 @@ import { useEffect } from "react"; import { navigate } from "@/client/useHashLocation"; +import { useAPIContext } from "./contexts/APIContext"; export const DeepLinkHandler = () => { + const { osAPI } = useAPIContext(); + useEffect(() => { - window.electronAPI.onDeepLink((link) => { + osAPI.onDeepLink((link) => { navigate(link.url); }); - }, []); + }, [osAPI]); return null; }; diff --git a/src/client/api/AuthAPI.ts b/src/client/api/AuthAPI.ts index defa976..56ff2e9 100644 --- a/src/client/api/AuthAPI.ts +++ b/src/client/api/AuthAPI.ts @@ -1,19 +1,19 @@ import { User, UserRole } from "@/types"; import { Fetcher } from "./Fetcher"; import { - ChangePasswordCredentials, + ChangePasswordDTO, IAuthAPI, - LoginCredentials, - RegisterCredentials, - ResetPasswordCredentials, + LoginDTO, + RegisterDTO, + ResetPasswordDTO, } from "./interfaces/IAuthAPI"; -const roleMap = { +export const roleMap = { ROLE_USER: UserRole.USER, ROLE_ADMIN: UserRole.ADMIN, } as const; -type ServerUser = { +export type ServerUser = { email: string; username: string; role: keyof typeof roleMap; @@ -26,7 +26,7 @@ export class AuthAPI implements IAuthAPI { this.fetcher = fetcher; } - register = async (credentials: RegisterCredentials): Promise => { + register = async (credentials: RegisterDTO): Promise => { await this.fetcher.post("/auth/registration", { body: credentials, }); @@ -37,7 +37,7 @@ export class AuthAPI implements IAuthAPI { }); }; - login = async (credentials: LoginCredentials): Promise => { + login = async (credentials: LoginDTO): Promise => { const user = await this.fetcher.post("/auth/login", { body: credentials, }); @@ -51,7 +51,7 @@ export class AuthAPI implements IAuthAPI { return { ...user, role: roleMap[user.role], id: "TODO" }; }; - changePassword = (credentials: ChangePasswordCredentials): Promise => { + changePassword = (credentials: ChangePasswordDTO): Promise => { return this.fetcher.post("/auth/auth-change-password", { body: { oldPassword: credentials.oldPassword, @@ -65,7 +65,7 @@ export class AuthAPI implements IAuthAPI { return this.fetcher.post("/auth/recover-password", { body: { email } }); }; - resetPassword = (credentials: ResetPasswordCredentials): Promise => { + resetPassword = (credentials: ResetPasswordDTO): Promise => { return this.fetcher.post("/auth/change-password", { body: { token: credentials.token, diff --git a/src/client/api/GameAPI.ts b/src/client/api/GameAPI.ts index e8c67ee..a4fc32b 100644 --- a/src/client/api/GameAPI.ts +++ b/src/client/api/GameAPI.ts @@ -3,7 +3,7 @@ import { Fetcher } from "./Fetcher"; import { AddGameDTO, IGameAPI, UpdateGameDTO } from "./interfaces/IGameAPI"; import { ResourceRequest, ResourceResponse } from "./interfaces/common"; -type GameFromServer = { +export type GameFromServer = { id: number; name: string; description: string; @@ -21,7 +21,15 @@ type GameFromServer = { type: string; label: string; description: string; - commonParameterId: number; + commonParameterDTO: { + id: number; + gameStateParameterTypeDTO: { + id: number; + type: string; + }; + label: string; + description: string; + }; }[]; }; imageUrl: string; @@ -37,7 +45,7 @@ export class GameAPI implements IGameAPI { getGame = async (gameId: string): Promise => { const game = await this.fetcher.get(`/games/${gameId}`); - return this.mapGameFromServer(game); + return GameAPI.mapGameFromServer(game); }; getGames = async ( @@ -51,7 +59,7 @@ export class GameAPI implements IGameAPI { ); return { - items: games.items.map(this.mapGameFromServer), + items: games.items.map(GameAPI.mapGameFromServer), totalCount: games.totalCount, }; }; @@ -72,8 +80,12 @@ export class GameAPI implements IGameAPI { gameStateParameters: game.gameStateParameters.parameters.map( (field) => ({ key: field.key, - type: field.type.type || field.type.id, - commonParameterId: field.commonParameter.id, + type: field.type.type, + commonParameterDTO: field.commonParameter.id + ? { + id: field.commonParameter.id, + } + : undefined, label: field.label, description: field.description, }) @@ -108,8 +120,12 @@ export class GameAPI implements IGameAPI { (field) => ({ id: field.id, key: field.key, - type: field.type.type || field.type.id, - commonParameterId: field.commonParameter.id, + type: field.type.type, + commonParameterDTO: field.commonParameter.id + ? { + id: field.commonParameter.id, + } + : undefined, label: field.label, description: field.description, }) @@ -128,7 +144,7 @@ export class GameAPI implements IGameAPI { return this.fetcher.delete(`/games/${gameId}`); }; - private mapGameFromServer = (game: GameFromServer): Game => { + static mapGameFromServer = (game: GameFromServer): Game => { return { id: game.id.toString(), name: game.name, @@ -144,15 +160,25 @@ export class GameAPI implements IGameAPI { type: field.type, id: field.type, }, - commonParameter: { - id: field.commonParameterId.toString(), - type: { - type: field.type, - id: field.type, - }, - label: "", - description: "", - }, + commonParameter: field.commonParameterDTO + ? { + id: field.commonParameterDTO.id.toString(), + type: { + type: field.commonParameterDTO.gameStateParameterTypeDTO.type, + id: field.commonParameterDTO.gameStateParameterTypeDTO.id.toString(), + }, + label: field.commonParameterDTO.label, + description: field.commonParameterDTO.description, + } + : { + id: "", + type: { + type: "", + id: "", + }, + label: "", + description: "", + }, label: field.label, description: field.description, })), diff --git a/src/client/api/GameStateAPI.ts b/src/client/api/GameStateAPI.ts index 375ec6b..a9137e6 100644 --- a/src/client/api/GameStateAPI.ts +++ b/src/client/api/GameStateAPI.ts @@ -1,11 +1,9 @@ import { GamePath, GameState, GameStateSync, Share } from "@/types"; import { IGameStateAPI } from "./interfaces/IGameStateAPI"; import { IOSAPI } from "./interfaces/IOSAPI"; -import { ApiError } from "./ApiError"; -import { IGameAPI } from "./interfaces/IGameAPI"; import { Fetcher } from "./Fetcher"; import { ResourceRequest, ResourceResponse } from "./interfaces/common"; -import { LocalStorage } from "./mocks/LocalStorage"; +import { LocalStorage } from "./LocalStorage"; const ls = new LocalStorage("game_states_not_mock_"); @@ -17,6 +15,8 @@ type GameStateFromServer = { id: number; gameStateParameterId: number; value: string; + label: string; + description: string; }[]; id: number; localPath: string; @@ -38,25 +38,25 @@ type GamePathFromServer = { gameIconUrl: string; }; +type SyncSettings = Record; + const apiPrefix = "/game-saves"; export class GameStateAPI implements IGameStateAPI { private readonly fetcher: Fetcher; private readonly osAPI: IOSAPI; - private readonly gameAPI: IGameAPI; - constructor(fetcher: Fetcher, osAPI: IOSAPI, gameAPI: IGameAPI) { + constructor(fetcher: Fetcher, osAPI: IOSAPI) { this.fetcher = fetcher; this.osAPI = osAPI; - this.gameAPI = gameAPI; } getStatePaths = async (): Promise => { const pathsFromServer = await this.fetcher.get<{ - items: GamePathFromServer[][]; + items: GamePathFromServer[]; }>(`/game-paths?pageSize=1000&pageNumber=1&searchQuery=""`); - const paths: GamePath[] = pathsFromServer.items[0].map((path) => ({ + const paths: GamePath[] = pathsFromServer.items.map((path) => ({ id: path.id.toString(), path: path.path, gameId: path.gameId.toString(), @@ -64,13 +64,7 @@ export class GameStateAPI implements IGameStateAPI { gameIconURL: path.gameIconUrl, })); - const response = await this.osAPI.getSavePaths(paths); - - if (!response.data) { - throw new ApiError(response.error || "Failed to get state paths"); - } - - return response.data; + return this.osAPI.getStatePaths(paths); }; getGameState = async (gameStateId: string): Promise => { @@ -78,16 +72,7 @@ export class GameStateAPI implements IGameStateAPI { `${apiPrefix}/${gameStateId}` ); - const syncSettings = this.getSyncSettings(); - - const mapped = this.mapGameStateFromServer(state); - - return { - ...mapped, - sync: syncSettings[mapped.id] - ? syncSettings[mapped.id].sync - : GameStateSync.NO, - }; + return this.mapGameStateFromServer(state, this.getSyncSettings()); }; getUserStates = async ( @@ -100,19 +85,10 @@ export class GameStateAPI implements IGameStateAPI { `${apiPrefix}?searchQuery=${query.searchQuery}&pageSize=${query.pageSize}&pageNumber=${query.pageNumber}` ); - const syncSettings = this.getSyncSettings(); - return { - items: states.items.map((state) => { - const mapped = this.mapGameStateFromServer(state); - - return { - ...mapped, - sync: syncSettings[mapped.id] - ? syncSettings[mapped.id].sync - : GameStateSync.NO, - }; - }), + items: states.items.map((i) => + this.mapGameStateFromServer(i, this.getSyncSettings()) + ), totalCount: states.totalCount, }; }; @@ -120,89 +96,58 @@ export class GameStateAPI implements IGameStateAPI { getSharedStates = async ( query: ResourceRequest ): Promise> => { - console.log("getSharedStates", query); + const states = await this.fetcher.get<{ + items: GameStateFromServer[]; + totalCount: number; + }>( + `${apiPrefix}/received-game-state-shares?searchQuery=${query.searchQuery}&pageSize=${query.pageSize}&pageNumber=${query.pageNumber}` + ); + return { - items: [], - totalCount: 0, + items: states.items.map((i) => + this.mapGameStateFromServer(i, this.getSyncSettings()) + ), + totalCount: states.totalCount, }; }; getPublicStates = async ( query: ResourceRequest ): Promise> => { - console.log("getGlobalStates", query); + const states = await this.fetcher.get<{ + items: GameStateFromServer[]; + totalCount: number; + }>( + `${apiPrefix}/public?searchQuery=${query.searchQuery}&pageSize=${query.pageSize}&pageNumber=${query.pageNumber}` + ); + return { - items: [], - totalCount: 0, + items: states.items.map((i) => + this.mapGameStateFromServer(i, this.getSyncSettings()) + ), + totalCount: states.totalCount, }; }; uploadState = async (state: { gameId?: string; - path: string; - name: string; - }): Promise => { - const game = state.gameId - ? await this.gameAPI.getGame(state.gameId) - : undefined; - - const response = await this.osAPI.uploadState(state, game); - - const formData = new FormData(); - formData.append("archive", new Blob([response.buffer])); - formData.append( - "gameStateData", - JSON.stringify({ - gameId: state.gameId, - name: game ? game.name : state.name, - localPath: state.path, - isPublic: false, - gameStateValues: response.gameStateValues.map((value) => ({ - value: value.value, - gameStateParameterId: value.gameStateParameterId, - })), - }) - ); - - await this.fetcher.post(`${apiPrefix}`, { - headers: {}, - body: formData, - }); - }; - - reuploadState = async (state: { - id: string; - gameId?: string; - path: string; + localPath: string; name: string; isPublic: boolean; }): Promise => { - const game = state.gameId - ? await this.gameAPI.getGame(state.gameId) - : undefined; + await this.osAPI.uploadState(state); + }; - const response = await this.osAPI.uploadState(state, game); + reuploadState = async (state: GameState): Promise => { + await this.osAPI.reuploadState(state); + }; - const formData = new FormData(); - formData.append("archive", new Blob([response.buffer])); - formData.append( - "gameStateData", - JSON.stringify({ - gameId: state.gameId, - name: game ? game.name : state.name, - localPath: state.path, - isPublic: state.isPublic, - gameStateValues: response.gameStateValues.map((value) => ({ - value: value.value, - gameStateParameterId: value.gameStateParameterId, - })), - }) - ); + downloadState = async (state: GameState): Promise => { + await this.osAPI.downloadState(state); + }; - await this.fetcher.patch(`${apiPrefix}/${state.id}`, { - headers: {}, - body: formData, - }); + downloadStateAs = async (state: GameState): Promise => { + await this.osAPI.downloadStateAs(state); }; setupSync = async (settings: { @@ -211,7 +156,7 @@ export class GameStateAPI implements IGameStateAPI { sync: GameStateSync; }) => { try { - const states = ls.getItem>("sync_settings"); + const states = ls.getItem("sync_settings"); states[settings.gameStateId] = { ...states[settings.gameStateId], sync: settings.sync, @@ -227,37 +172,31 @@ export class GameStateAPI implements IGameStateAPI { } }; - getSyncSettings(): Record { + getSyncSettings(): SyncSettings { try { - const syncSetting = - ls.getItem>( - "sync_settings" - ); + const syncSetting = ls.getItem("sync_settings"); return syncSetting; } catch (e) { return {}; } } - downloadState = async (gameState: GameState) => { - await this.osAPI.downloadState(gameState); - }; - - downloadStateAs = async (gameState: GameState) => { - await this.osAPI.downloadStateAs(gameState); - }; - deleteState = async (gameStateId: string): Promise => { await this.fetcher.delete(`${apiPrefix}/${gameStateId}`); }; - private mapGameStateFromServer = (state: GameStateFromServer): GameState => { + private mapGameStateFromServer = ( + state: GameStateFromServer, + syncSettings: SyncSettings + ): GameState => { return { id: state.id.toString(), gameId: state.gameId.toString(), gameIconURL: state.gameIconUrl, name: state.name, - sync: GameStateSync.NO, + sync: syncSettings[state.id] + ? syncSettings[state.id].sync + : GameStateSync.NO, isPublic: state.isPublic, localPath: state.localPath, archiveURL: state.archiveUrl, @@ -265,9 +204,9 @@ export class GameStateAPI implements IGameStateAPI { gameStateValues: state.gameStateValues.map((value) => ({ value: value.value, gameStateParameterId: value.gameStateParameterId.toString(), - label: "value.gameStateParameter.label", - type: "value.gameStateParameter.type", - description: "value.gameStateParameter.description", + type: "type", + label: value.label, + description: value.description, })), uploadedAt: new Date().toLocaleString(), createdAt: new Date().toLocaleString(), @@ -280,18 +219,11 @@ export class GameStateAPI implements IGameStateAPI { gameStateId: string; userId: string; }): Promise => { - const formData = new FormData(); - formData.append( - "gameStateSharedData", - JSON.stringify({ + await this.fetcher.post(`/game-state-shares`, { + body: { gameStateId: share.gameStateId, shareWithId: share.userId, - }) - ); - - await this.fetcher.post(`/game-state-shares`, { - headers: {}, - body: formData, + }, }); }; diff --git a/src/client/api/GameStateParameterTypesAPI.ts b/src/client/api/GameStateParameterTypesAPI.ts new file mode 100644 index 0000000..30e909e --- /dev/null +++ b/src/client/api/GameStateParameterTypesAPI.ts @@ -0,0 +1,43 @@ +import { GameStateParameterType } from "@/types"; +import { IGameStateParameterTypeAPI } from "./interfaces/IGameStateParameterTypeAPI"; +import { ResourceRequest, ResourceResponse } from "./interfaces/common"; +import { Fetcher } from "./Fetcher"; + +export class GameStateParameterTypesAPI implements IGameStateParameterTypeAPI { + private readonly fetcher: Fetcher; + + constructor(fetcher: Fetcher) { + this.fetcher = fetcher; + } + + getTypes = async ( + query: ResourceRequest + ): Promise> => { + const types = await this.fetcher.get< + ResourceResponse + >( + `/game-state-parameter-types?pageNumber=${query.pageNumber}&pageSize=${query.pageSize}&searchQuery=${query.searchQuery}` + ); + + return types; + }; + + createType = async ( + type: GameStateParameterType + ): Promise => { + console.log("create type", type); + throw new Error("Method not implemented."); + }; + + updateType = async ( + type: GameStateParameterType + ): Promise => { + console.log("update type", type); + throw new Error("Method not implemented."); + }; + + deleteType = async (typeId: string): Promise => { + console.log("delete type", typeId); + throw new Error("Method not implemented."); + }; +} diff --git a/src/client/api/mocks/LocalStorage.ts b/src/client/api/LocalStorage.ts similarity index 100% rename from src/client/api/mocks/LocalStorage.ts rename to src/client/api/LocalStorage.ts diff --git a/src/client/api/OSAPI.ts b/src/client/api/OSAPI.ts index 107f698..a133007 100644 --- a/src/client/api/OSAPI.ts +++ b/src/client/api/OSAPI.ts @@ -1,58 +1,67 @@ -import { Game, GamePath, GameState } from "@/types"; +import { GamePath, GameState } from "@/types"; import { IOSAPI } from "./interfaces/IOSAPI"; import { ApiError } from "./ApiError"; export class OSAPI implements IOSAPI { - getSavePaths = async ( - paths: GamePath[] - ): Promise> => { - const response = await window.electronAPI.getSavePaths(paths); - - return response; - }; - getFolderInfo = async (folderPath: string) => { const response = await window.electronAPI.getFolderInfo(folderPath); - if (!response.data) { throw response.error; } - return response.data; }; showFolderDialog = async () => { const response = await window.electronAPI.showFolderDialog(); - if (!response.data) { throw response.error; } + return response.data; + }; + + onDeepLink = (callback: (link: { url: string }) => void): void => { + return window.electronAPI.onDeepLink(callback); + }; + + onGetSyncedSaves = (callback: () => void): void => { + return window.electronAPI.onGetSyncedStates(callback); + }; + + sendSyncedSaves = async (args: GameState[]): Promise => { + await window.electronAPI.sendSyncedStates(args); + }; + getStatePaths = async (paths: GamePath[]): Promise => { + const response = await window.electronAPI.getStatePaths(paths); + if (!response.data) { + throw new ApiError(response.error || "Failed to get state paths"); + } return response.data; }; - uploadState = async ( - state: { - path: string; - name: string; - }, - game: Game - ): Promise<{ + uploadState = async (state: { + gameId?: string; + localPath: string; + name: string; + isPublic: boolean; + }): Promise<{ buffer: Buffer; - gameStateValues: { - gameStateParameterId: string; - value: string; - }[]; + gameStateValues: { value: string; gameStateParameterId: string }[]; }> => { - const response = await window.electronAPI.uploadSave(state, game); - + const response = await window.electronAPI.uploadState(state); if (!response.data) { throw new ApiError(response.error || "Failed to upload state"); } - return response.data; }; + reuploadState = async (state: GameState): Promise => { + const response = await window.electronAPI.reuploadState(state); + if (!response.data) { + throw new ApiError(response.error || "Failed to reupload state"); + } + }; + downloadState = async (gameState: GameState): Promise => { await window.electronAPI.downloadState(gameState); }; diff --git a/src/client/api/UsersAPI.ts b/src/client/api/UsersAPI.ts index 89376c3..5a0dfc8 100644 --- a/src/client/api/UsersAPI.ts +++ b/src/client/api/UsersAPI.ts @@ -1,8 +1,16 @@ -import { UserRole } from "@/types"; +import { roleMap } from "./AuthAPI"; import { Fetcher } from "./Fetcher"; import { IUsersAPI, UserForAdmin } from "./interfaces/IUsersAPI"; import { ResourceRequest, ResourceResponse } from "./interfaces/common"; +type UserFromServer = { + id: number; + username: string; + email: string; + role: "ROLE_ADMIN" | "ROLE_USER"; + isBlocked: boolean; +}; + export class UsersAPI implements IUsersAPI { private readonly fetcher: Fetcher; @@ -13,10 +21,7 @@ export class UsersAPI implements IUsersAPI { getUsers = async ( query: ResourceRequest ): Promise> => { - const users = await this.fetcher.get<{ - items: UserForAdmin[]; - totalCount: number; - }>( + const users = await this.fetcher.get>( `/users?searchQuery=${query.searchQuery}&pageSize=${query.pageSize}&pageNumber=${query.pageNumber}` ); @@ -25,7 +30,7 @@ export class UsersAPI implements IUsersAPI { id: user.id.toString(), username: user.username, email: user.email, - role: UserRole.USER, + role: roleMap[user.role], isBlocked: user.isBlocked, })), totalCount: users.totalCount, diff --git a/src/client/api/interfaces/IAuthAPI.ts b/src/client/api/interfaces/IAuthAPI.ts index af3a1cf..2938c71 100644 --- a/src/client/api/interfaces/IAuthAPI.ts +++ b/src/client/api/interfaces/IAuthAPI.ts @@ -1,38 +1,38 @@ import { User } from "@/types"; -export type LoginCredentials = { +export type LoginDTO = { username: string; password: string; }; -export type RegisterCredentials = { +export type RegisterDTO = { email: string; username: string; password: string; }; -export type ChangePasswordCredentials = { +export type ChangePasswordDTO = { oldPassword: string; newPassword: string; }; -export type ResetPasswordCredentials = { +export type ResetPasswordDTO = { token: string; newPassword: string; }; export interface IAuthAPI { - register(credentials: RegisterCredentials): Promise; + register(credentials: RegisterDTO): Promise; - login(credentials: LoginCredentials): Promise; + login(credentials: LoginDTO): Promise; getCurrentUser(): Promise; - changePassword(credentials: ChangePasswordCredentials): Promise; + changePassword(credentials: ChangePasswordDTO): Promise; requestPasswordReset(email: string): Promise; - resetPassword(credentials: ResetPasswordCredentials): Promise; + resetPassword(credentials: ResetPasswordDTO): Promise; logout(): Promise; } diff --git a/src/client/api/interfaces/IGameStateAPI.ts b/src/client/api/interfaces/IGameStateAPI.ts index 31f9efb..13a3dd4 100644 --- a/src/client/api/interfaces/IGameStateAPI.ts +++ b/src/client/api/interfaces/IGameStateAPI.ts @@ -6,28 +6,23 @@ export interface IGameStateAPI { uploadState(state: { gameId?: string; - path: string; - name: string; - }): Promise; - - reuploadState(state: { - id: string; - gameId?: string; - path: string; + localPath: string; name: string; isPublic: boolean; }): Promise; + reuploadState(state: GameState): Promise; + + downloadState(state: GameState): Promise; + + downloadStateAs(state: GameState): Promise; + setupSync(settings: { userId: string; gameStateId: string; sync: GameStateSync; }): Promise; - downloadState(gameState: GameState): Promise; - - downloadStateAs(gameState: GameState): Promise; - deleteState(gameStateId: string): Promise; getGameState(gameStateId: string): Promise; diff --git a/src/client/api/interfaces/IGameStateParameterTypeAPI.ts b/src/client/api/interfaces/IGameStateParameterTypeAPI.ts index 125936c..5c0a303 100644 --- a/src/client/api/interfaces/IGameStateParameterTypeAPI.ts +++ b/src/client/api/interfaces/IGameStateParameterTypeAPI.ts @@ -11,8 +11,6 @@ export interface IGameStateParameterTypeAPI { query: ResourceRequest ) => Promise>; - getType: (typeId: string) => Promise; - createType: (type: GameStateParameterType) => Promise; updateType: (type: GameStateParameterType) => Promise; diff --git a/src/client/api/interfaces/IOSAPI.ts b/src/client/api/interfaces/IOSAPI.ts index 3e7af7a..8dc404f 100644 --- a/src/client/api/interfaces/IOSAPI.ts +++ b/src/client/api/interfaces/IOSAPI.ts @@ -1,29 +1,33 @@ -import { Game, GamePath, GameState } from "@/types"; +import { GamePath, GameState } from "@/types"; export interface IOSAPI { - getSavePaths: (paths: GamePath[]) => Promise>; - getFolderInfo(folderPath: string): Promise; showFolderDialog(): Promise; - uploadState( - state: { - path: string; - name: string; - }, - game?: Game - ): Promise<{ + onDeepLink: (callback: (link: { url: string }) => void) => void; + + onGetSyncedSaves: (callback: () => void) => void; + + sendSyncedSaves: (args: GameState[]) => Promise; + + getStatePaths: (paths: GamePath[]) => Promise; + + uploadState(state: { + gameId?: string; + localPath: string; + name: string; + isPublic: boolean; + }): Promise<{ buffer: Buffer; - gameStateValues: { - gameStateParameterId: string; - value: string; - }[]; + gameStateValues: { value: string; gameStateParameterId: string }[]; }>; + reuploadState(state: GameState): Promise; + // Download and extract to states folder of the game downloadState(gameState: GameState): Promise; - // Just download + // Just download to selected folder downloadStateAs(gameState: GameState): Promise; } diff --git a/src/client/api/mocks/AuthAPIMock.ts b/src/client/api/mocks/AuthAPIMock.ts index 79588ef..54d8001 100644 --- a/src/client/api/mocks/AuthAPIMock.ts +++ b/src/client/api/mocks/AuthAPIMock.ts @@ -1,13 +1,13 @@ import { User, UserRole } from "@/types"; import { - ChangePasswordCredentials, + ChangePasswordDTO, IAuthAPI, - LoginCredentials, - RegisterCredentials, - ResetPasswordCredentials, + LoginDTO, + RegisterDTO, + ResetPasswordDTO, } from "../interfaces/IAuthAPI"; import { ApiError } from "../ApiError"; -import { LocalStorage } from "./LocalStorage"; +import { LocalStorage } from "../LocalStorage"; const ls = new LocalStorage("users_mock"); @@ -16,7 +16,7 @@ export class AuthAPIMock implements IAuthAPI { return new Promise((resolve) => setTimeout(resolve, ms)); }; - register = async (credentials: RegisterCredentials): Promise => { + register = async (credentials: RegisterDTO): Promise => { await this.sleep(500); const user = { ...credentials, @@ -64,7 +64,7 @@ export class AuthAPIMock implements IAuthAPI { }; }; - login = async (credentials: LoginCredentials): Promise => { + login = async (credentials: LoginDTO): Promise => { await this.sleep(500); try { const users = ls.getItem<(User & { password: string })[]>("users"); @@ -103,9 +103,7 @@ export class AuthAPIMock implements IAuthAPI { return user; }; - changePassword = async ( - credentials: ChangePasswordCredentials - ): Promise => { + changePassword = async (credentials: ChangePasswordDTO): Promise => { await this.sleep(500); console.log("changePassword", credentials); throw new ApiError("Not implemented"); @@ -118,9 +116,7 @@ export class AuthAPIMock implements IAuthAPI { throw new ApiError("Not implemented"); }; - resetPassword = async ( - credentials: ResetPasswordCredentials - ): Promise => { + resetPassword = async (credentials: ResetPasswordDTO): Promise => { await this.sleep(500); console.log("resetPassword", credentials); diff --git a/src/client/api/mocks/GameAPIMock.ts b/src/client/api/mocks/GameAPIMock.ts index 85fc597..889f8be 100644 --- a/src/client/api/mocks/GameAPIMock.ts +++ b/src/client/api/mocks/GameAPIMock.ts @@ -2,7 +2,7 @@ import { Game } from "@/types"; import { ApiError } from "../ApiError"; import { AddGameDTO, IGameAPI, UpdateGameDTO } from "../interfaces/IGameAPI"; import { ResourceRequest, ResourceResponse } from "../interfaces/common"; -import { LocalStorage } from "./LocalStorage"; +import { LocalStorage } from "../LocalStorage"; const ls = new LocalStorage("games_"); diff --git a/src/client/api/mocks/GameStateAPIMock.ts b/src/client/api/mocks/GameStateAPIMock.ts index dea96aa..c7bf6e6 100644 --- a/src/client/api/mocks/GameStateAPIMock.ts +++ b/src/client/api/mocks/GameStateAPIMock.ts @@ -4,7 +4,7 @@ import { IOSAPI } from "../interfaces/IOSAPI"; import { ApiError } from "../ApiError"; import { IGameAPI } from "../interfaces/IGameAPI"; import { ResourceRequest, ResourceResponse } from "../interfaces/common"; -import { LocalStorage } from "./LocalStorage"; +import { LocalStorage } from "../LocalStorage"; const ls = new LocalStorage("game_states_"); @@ -37,13 +37,7 @@ export class GameStateAPIMock implements IGameStateAPI { } } - const response = await this.osAPI.getSavePaths(paths); - - if (!response.data) { - throw new ApiError(response.error || "Failed to get state paths"); - } - - return response.data; + return this.osAPI.getStatePaths(paths); }; getGameState = async (gameStateId: string): Promise => { @@ -118,21 +112,26 @@ export class GameStateAPIMock implements IGameStateAPI { uploadState = async (state: { gameId?: string; - path: string; + localPath: string; name: string; + isPublic: boolean; }): Promise => { const game = state.gameId ? await this.gameAPI.getGame(state.gameId) : undefined; - const response = await this.osAPI.uploadState(state, game); + const response = await this.osAPI.uploadState(state); - const gameStateId = state.path.split("/").join("-").split(" ").join("_"); + const gameStateId = state.localPath + .split("/") + .join("-") + .split(" ") + .join("_"); const gameState: GameState = { id: gameStateId, - gameId: state.path, + gameId: state.localPath, gameIconURL: "", - localPath: state.path, + localPath: state.localPath, name: game ? game.name : state.name, sync: GameStateSync.NO, isPublic: false, @@ -143,7 +142,7 @@ export class GameStateAPIMock implements IGameStateAPI { label: "label", description: "description", })), - archiveURL: state.path, + archiveURL: state.localPath, sizeInBytes: 42, uploadedAt: new Date().toLocaleString(), updatedAt: new Date().toLocaleString(), @@ -161,25 +160,23 @@ export class GameStateAPIMock implements IGameStateAPI { } }; - reuploadState = async (state: { - id: string; - gameId?: string; - path: string; - name: string; - isPublic: boolean; - }): Promise => { + reuploadState = async (state: GameState): Promise => { const game = state.gameId ? await this.gameAPI.getGame(state.gameId) : undefined; - const response = await this.osAPI.uploadState(state, game); + const response = await this.osAPI.uploadState(state); - const gameStateId = state.path.split("/").join("-").split(" ").join("_"); + const gameStateId = state.localPath + .split("/") + .join("-") + .split(" ") + .join("_"); const gameState: GameState = { id: gameStateId, - gameId: state.path, + gameId: game ? game.id : Math.random().toString(), gameIconURL: "", - localPath: state.path, + localPath: state.localPath, name: game ? game.name : state.name, sync: GameStateSync.NO, isPublic: state.isPublic, @@ -190,7 +187,7 @@ export class GameStateAPIMock implements IGameStateAPI { label: "label", description: "description", })), - archiveURL: state.path, + archiveURL: state.localPath, sizeInBytes: 42, uploadedAt: new Date().toLocaleString(), updatedAt: new Date().toLocaleString(), @@ -208,6 +205,14 @@ export class GameStateAPIMock implements IGameStateAPI { } }; + downloadState = async (state: GameState): Promise => { + await this.osAPI.downloadState(state); + }; + + downloadStateAs = async (state: GameState): Promise => { + await this.osAPI.downloadStateAs(state); + }; + setupSync = async (settings: { userId: string; gameStateId: string; @@ -242,14 +247,6 @@ export class GameStateAPIMock implements IGameStateAPI { } } - downloadState = async (gameState: GameState) => { - await this.osAPI.downloadState(gameState); - }; - - downloadStateAs = async (gameState: GameState) => { - await this.osAPI.downloadStateAs(gameState); - }; - deleteState = async (gameStateId: string): Promise => { try { const states = ls.getItem>("states"); diff --git a/src/client/api/mocks/UsersAPIMock.ts b/src/client/api/mocks/UsersAPIMock.ts index d48fa94..e2cdb72 100644 --- a/src/client/api/mocks/UsersAPIMock.ts +++ b/src/client/api/mocks/UsersAPIMock.ts @@ -2,7 +2,7 @@ import { User, UserRole } from "@/types"; import { IUsersAPI, UserForAdmin } from "../interfaces/IUsersAPI"; import { ApiError } from "../ApiError"; import { ResourceRequest, ResourceResponse } from "../interfaces/common"; -import { LocalStorage } from "./LocalStorage"; +import { LocalStorage } from "../LocalStorage"; const ls = new LocalStorage("users_mock_"); diff --git a/src/client/app.tsx b/src/client/app.tsx index c42e205..ace4bb4 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -12,7 +12,7 @@ import { GameState } from "@/types"; function bootstrap() { initI18n(); - window.electronAPI.onGetSyncedSaves(async () => { + api.osAPI.onGetSyncedSaves(async () => { const statesMap = await api.gameStateAPI.getSyncSettings(); const states: GameState[] = []; @@ -29,7 +29,7 @@ function bootstrap() { } } - window.electronAPI.sendSyncedSaves(states); + api.osAPI.sendSyncedSaves(states); }); const rootElement = document.getElementById("app"); diff --git a/src/client/config/paths.tsx b/src/client/config/paths.tsx index de44ee1..81bde71 100644 --- a/src/client/config/paths.tsx +++ b/src/client/config/paths.tsx @@ -9,6 +9,7 @@ const profile = path("/"); const mySaves = path("/my-saves"); const localSaves = mySaves.path("/local"); const mySave = mySaves.path("/:gameStateId"); +const save = path("/saves/:gameStateId"); const sharedSaves = path("/shared-saves"); const publicSaves = path("/public-saves"); @@ -28,6 +29,7 @@ export const paths = { mySaves, localSaves, mySave, + save, sharedSaves, publicSaves, diff --git a/src/client/config/routes.tsx b/src/client/config/routes.tsx index b9b2d6a..d783dde 100644 --- a/src/client/config/routes.tsx +++ b/src/client/config/routes.tsx @@ -8,8 +8,9 @@ import { ResetPasswordPage } from "@/client/pages/ResetPassword/ResetPasswordPag import { ProfilePage } from "@/client/pages/Profile/ProfilePage"; import { MySavesPage } from "@/client/pages/MySaves/MySavesPage"; import { MySavePage } from "@/client/pages/MySaves/MySave/MySavePage"; -import { SharedSavesPage } from "@/client/pages/SharedSaves/SharedSavesPage"; -import { PublicSavesPage } from "@/client/pages/PublicSaves/PublicSavesPage"; +import { SavePage } from "../pages/saves/Save/SavePage"; +import { SharedSavesPage } from "@/client/pages/saves/SharedSaves/SharedSavesPage"; +import { PublicSavesPage } from "@/client/pages/saves/PublicSaves/PublicSavesPage"; import { GamesPage } from "@/client/pages/Games/GamesPage"; import { GamePage } from "@/client/pages/Games/Game/GamePage"; @@ -116,6 +117,12 @@ export const routes: RouteDescriptor[] = [ access: RouteAccess.AUTHENTICATED, forRoles: [UserRole.USER], }, + { + path: paths.save.pattern, + component: SavePage, + access: RouteAccess.AUTHENTICATED, + forRoles: [UserRole.USER], + }, { path: paths.sharedSaves.pattern, component: SharedSavesPage, diff --git a/src/client/contexts/APIContext/APIContext.ts b/src/client/contexts/APIContext/APIContext.ts index 072a4f1..6381d98 100644 --- a/src/client/contexts/APIContext/APIContext.ts +++ b/src/client/contexts/APIContext/APIContext.ts @@ -5,22 +5,25 @@ import { IAuthAPI } from "@/client/api/interfaces/IAuthAPI"; import { IGameStateAPI } from "@/client/api/interfaces/IGameStateAPI"; import { IGameAPI } from "@/client/api/interfaces/IGameAPI"; import { IUsersAPI } from "@/client/api/interfaces/IUsersAPI"; +import { IGameStateParameterTypeAPI } from "@/client/api/interfaces/IGameStateParameterTypeAPI"; +import { ICommonParametersAPI } from "@/client/api/interfaces/ICommonParametersAPI"; + +import { Fetcher } from "@/client/api/Fetcher"; import { OSAPI } from "@/client/api/OSAPI"; import { AuthAPIMock } from "@/client/api/mocks/AuthAPIMock"; import { GameStateAPIMock } from "@/client/api/mocks/GameStateAPIMock"; import { GameAPIMock } from "@/client/api/mocks/GameAPIMock"; import { UsersAPIMock } from "@/client/api/mocks/UsersAPIMock"; -import { AuthAPI } from "@/client/api/AuthAPI"; -import { GameAPI } from "@/client/api/GameAPI"; -import { Fetcher } from "@/client/api/Fetcher"; -import { ICommonParametersAPI } from "@/client/api/interfaces/ICommonParametersAPI"; -import { IGameStateParameterTypeAPI } from "@/client/api/interfaces/IGameStateParameterTypeAPI"; import { CommonParametersAPIMock } from "@/client/api/mocks/CommonParametersAPIMock"; import { GameStateParameterTypesAPIMock } from "@/client/api/mocks/GameStateParameterTypesAPIMock"; + +import { AuthAPI } from "@/client/api/AuthAPI"; +import { GameAPI } from "@/client/api/GameAPI"; import { GameStateAPI } from "@/client/api/GameStateAPI"; import { CommonParametersAPI } from "@/client/api/CommonParametersAPI"; import { UsersAPI } from "@/client/api/UsersAPI"; +import { GameStateParameterTypesAPI } from "@/client/api/GameStateParameterTypesAPI"; interface APIContext { osAPI: IOSAPI; @@ -43,14 +46,15 @@ const osAPI = new OSAPI(); const authAPI = API_BASE_URL ? new AuthAPI(fetcher) : new AuthAPIMock(); const gameAPI = API_BASE_URL ? new GameAPI(fetcher) : new GameAPIMock(); const gameStateAPI = API_BASE_URL - ? new GameStateAPI(fetcher, osAPI, gameAPI) + ? new GameStateAPI(fetcher, osAPI) : new GameStateAPIMock(osAPI, gameAPI); const usersAPI = API_BASE_URL ? new UsersAPI(fetcher) : new UsersAPIMock(); - const commonParametersAPI = API_BASE_URL ? new CommonParametersAPI(fetcher) : new CommonParametersAPIMock(); -const parameterTypesAPI = new GameStateParameterTypesAPIMock(); +const parameterTypesAPI = API_BASE_URL + ? new GameStateParameterTypesAPI(fetcher) + : new GameStateParameterTypesAPIMock(); export const api = { osAPI, @@ -60,6 +64,6 @@ export const api = { usersAPI, commonParametersAPI, parameterTypesAPI, -}; +} satisfies APIContext; export const APIContext = createContext(api); diff --git a/src/client/contexts/AuthContext/AuthContext.ts b/src/client/contexts/AuthContext/AuthContext.ts index 35fe8f2..47199ca 100644 --- a/src/client/contexts/AuthContext/AuthContext.ts +++ b/src/client/contexts/AuthContext/AuthContext.ts @@ -2,10 +2,10 @@ import { createContext } from "react"; import { User, UserRole } from "@/types"; import { - ChangePasswordCredentials, - LoginCredentials, - RegisterCredentials, - ResetPasswordCredentials, + ChangePasswordDTO, + LoginDTO, + RegisterDTO, + ResetPasswordDTO, } from "@/client/api/interfaces/IAuthAPI"; export const emptyUser: User = { @@ -26,15 +26,15 @@ interface AuthContext { authStatus: AuthStatus; user: User; - register: (credentials: RegisterCredentials) => Promise; + register: (credentials: RegisterDTO) => Promise; - login: (credentials: LoginCredentials) => Promise; + login: (credentials: LoginDTO) => Promise; - changePassword: (credentials: ChangePasswordCredentials) => Promise; + changePassword: (credentials: ChangePasswordDTO) => Promise; requestPasswordReset: (email: string) => Promise; - resetPassword: (credentials: ResetPasswordCredentials) => Promise; + resetPassword: (credentials: ResetPasswordDTO) => Promise; logout: () => Promise; } diff --git a/src/client/contexts/AuthContext/AuthContextProvider.tsx b/src/client/contexts/AuthContext/AuthContextProvider.tsx index d94c2b4..391bd40 100644 --- a/src/client/contexts/AuthContext/AuthContextProvider.tsx +++ b/src/client/contexts/AuthContext/AuthContextProvider.tsx @@ -5,10 +5,10 @@ import { User } from "@/types"; import { paths } from "@/client/config/paths"; import { - ChangePasswordCredentials, - LoginCredentials, - RegisterCredentials, - ResetPasswordCredentials, + ChangePasswordDTO, + LoginDTO, + RegisterDTO, + ResetPasswordDTO, } from "@/client/api/interfaces/IAuthAPI"; import { useAPIContext } from "../APIContext"; import { AuthStatus, emptyUser, AuthContext } from "./AuthContext"; @@ -29,37 +29,31 @@ export const AuthContextProvider = (props: { children: ReactNode }) => { .catch(() => setAuthStatus(AuthStatus.ANONYMOUS)); }, []); - const register = useCallback(async (credentials: RegisterCredentials) => { + const register = useCallback(async (credentials: RegisterDTO) => { const user = await authAPI.register(credentials); setUser(user); setAuthStatus(AuthStatus.AUTHENTICATED); }, []); - const login = useCallback(async (credentials: LoginCredentials) => { + const login = useCallback(async (credentials: LoginDTO) => { const user = await authAPI.login(credentials); setUser(user); setAuthStatus(AuthStatus.AUTHENTICATED); }, []); - const changePassword = useCallback( - async (credentials: ChangePasswordCredentials) => { - await authAPI.changePassword(credentials); - }, - [] - ); + const changePassword = useCallback(async (credentials: ChangePasswordDTO) => { + await authAPI.changePassword(credentials); + }, []); const requestPasswordReset = useCallback(async (email: string) => { await authAPI.requestPasswordReset(email); }, []); - const resetPassword = useCallback( - async (credentials: ResetPasswordCredentials) => { - await authAPI.resetPassword(credentials); - }, - [] - ); + const resetPassword = useCallback(async (credentials: ResetPasswordDTO) => { + await authAPI.resetPassword(credentials); + }, []); const logout = useCallback(async () => { await authAPI.logout(); diff --git a/src/client/lib/components/GameStateCard/GameStateCard.tsx b/src/client/lib/components/GameStateCard/GameStateCard.tsx new file mode 100644 index 0000000..4b419ee --- /dev/null +++ b/src/client/lib/components/GameStateCard/GameStateCard.tsx @@ -0,0 +1,60 @@ +import { GameState } from "@/types"; + +import classes from "./game-state-card.module.scss"; +import { clsx } from "clsx"; +import { Link } from "wouter"; +import { ThreeDotsMenu } from "@/client/ui/molecules/ThreeDotsMenu"; +import { useTranslation } from "react-i18next"; +import { syncMap } from "@/client/pages/MySaves/utils"; +import { useConfirmModal } from "@/client/ui/hooks/useConfirmModal/useConfirmModal"; + +export type GameStateCardProps = { + gameState: GameState; + className?: string; + onDelete?: (gameStateId: string) => void; + href: string; +}; + +export const GameStateCard = (props: GameStateCardProps) => { + const { t } = useTranslation(undefined, { + keyPrefix: "components.GameStateCard", + }); + + const { modal, onClick: onDelete } = useConfirmModal({ + onConfirm: () => props.onDelete?.(props.gameState.id), + }); + + return ( +
+ +
+ {props.onDelete && ( + onDelete(), + children: "Delete", + key: "delete", + }, + ]} + /> + )} + {modal} + +
+

{props.gameState.name}

+

+ {t("sync")}: {t(syncMap[props.gameState.sync])} +

+
+
+ +
+ ); +}; diff --git a/src/client/lib/components/GameStateCard/game-state-card.module.scss b/src/client/lib/components/GameStateCard/game-state-card.module.scss new file mode 100644 index 0000000..4d61ecb --- /dev/null +++ b/src/client/lib/components/GameStateCard/game-state-card.module.scss @@ -0,0 +1,46 @@ +.GameStateCard { + min-width: 200px; + min-height: 250px; + display: flex; + flex-direction: column; + border-radius: 6px; + overflow: hidden; +} + +.GameStateLink { + flex: 1; + display: flex; + flex-direction: column; + text-decoration: none; +} + +.GameStateCardInner { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0.75rem 1rem; + + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(0, 0, 0, 0.8) 60%, + rgba(0, 0, 0, 0.9) 100% + ); + background-position: 0% 50%; + background-size: 200% 200%; + transition: background-position 0.3s ease; + + &:hover { + background-position: 0% 60%; + } +} + +.GameStateActions { + margin-left: auto; +} + +.GameStateInfo { + margin-top: auto; + color: white; +} diff --git a/src/client/lib/components/GameStateCard/index.ts b/src/client/lib/components/GameStateCard/index.ts new file mode 100644 index 0000000..34808fc --- /dev/null +++ b/src/client/lib/components/GameStateCard/index.ts @@ -0,0 +1 @@ +export * from "./GameStateCard"; diff --git a/src/client/lib/components/ParametersView/ParametersView.tsx b/src/client/lib/components/ParametersView/ParametersView.tsx new file mode 100644 index 0000000..9595b4c --- /dev/null +++ b/src/client/lib/components/ParametersView/ParametersView.tsx @@ -0,0 +1,61 @@ +import classes from "./parameters-view.module.scss"; + +import { GameStateValue } from "@/types"; + +type ParametersViewProps = { + gameStateValues: GameStateValue[]; +}; + +export const ParametersView = (props: ParametersViewProps) => { + return ( +
+ {props.gameStateValues.map((field, idx) => ( + + ))} +
+ ); +}; + +function formatTime(value: number, type: "seconds") { + if (type === "seconds") { + if (value < 60) { + return `${value} seconds`; + } + const minutes = Math.floor(value / 60); + + if (minutes < 60) { + return `${minutes} minutes`; + } + + const hours = Math.floor(minutes / 60); + + return `${hours} hours`; + } + + return `${value}`; +} + +const ParameterViewItem = (props: { + label: string; + value: string; + type: string; + description: string; +}) => { + if (props.type === "seconds") { + return ( +
+ {props.label}:{" "} + + {formatTime(parseFloat(props.value), props.type)} + +
+ ); + } + + return ( +
+ {props.label}:{" "} + {props.value.toString()} +
+ ); +}; diff --git a/src/client/pages/PublicSaves/public-saves-page.module.scss b/src/client/lib/components/ParametersView/index.ts similarity index 100% rename from src/client/pages/PublicSaves/public-saves-page.module.scss rename to src/client/lib/components/ParametersView/index.ts diff --git a/src/client/lib/components/ParametersView/parameters-view.module.scss b/src/client/lib/components/ParametersView/parameters-view.module.scss new file mode 100644 index 0000000..6142433 --- /dev/null +++ b/src/client/lib/components/ParametersView/parameters-view.module.scss @@ -0,0 +1,3 @@ +.ParameterValue { + font-weight: bold; +} diff --git a/src/client/lib/components/SharesWidget/SharesWidget.tsx b/src/client/lib/components/SharesWidget/SharesWidget.tsx index 7aca6eb..6dbda01 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.id, + value: user.id.toString(), })); }, }, diff --git a/src/client/lib/hooks/useResource.ts b/src/client/lib/hooks/useResource.ts index 7ce9a1f..b459879 100644 --- a/src/client/lib/hooks/useResource.ts +++ b/src/client/lib/hooks/useResource.ts @@ -9,12 +9,13 @@ export const useResource = < T, Q extends { searchQuery: string; pageNumber: number; pageSize: number } >( - loadResource: (query: Q) => Promise<{ items: T[]; totalCount: number }> + loadResource: (query: Q) => Promise<{ items: T[]; totalCount: number }>, + options?: { pageSize: number } ) => { const [query, setQuery] = useState(() => ({ searchQuery: "", pageNumber: 1, - pageSize: 12, + pageSize: options?.pageSize || 12, })); const [resource, setResource] = useState<{ diff --git a/src/client/locales/en/components/GameStateCard.json b/src/client/locales/en/components/GameStateCard.json new file mode 100644 index 0000000..6e48611 --- /dev/null +++ b/src/client/locales/en/components/GameStateCard.json @@ -0,0 +1,8 @@ +{ + "sync": "Sync", + "every-hour": "Every hour", + "every-day": "Every day", + "every-week": "Every week", + "every-month": "Every month", + "no": "No" +} diff --git a/src/client/locales/en/pages/mySaves.json b/src/client/locales/en/pages/mySaves.json index ffe297f..ab5d920 100644 --- a/src/client/locales/en/pages/mySaves.json +++ b/src/client/locales/en/pages/mySaves.json @@ -6,12 +6,6 @@ "home": "Home", "choose-folder-to-list-files": "Choose folder to list files", "upload": "Upload", - "sync": "Sync", - "every-hour": "Every hour", - "every-day": "Every day", - "every-week": "Every week", - "every-month": "Every month", - "no": "No", "local-saves": "Local Saves", "folder": "Folder", "file": "File" diff --git a/src/client/locales/resources-en.ts b/src/client/locales/resources-en.ts index df76c98..556631f 100644 --- a/src/client/locales/resources-en.ts +++ b/src/client/locales/resources-en.ts @@ -15,6 +15,8 @@ import sharedSaves from "./en/pages/sharedSaves.json"; import publicSaves from "./en/pages/publicSaves.json"; import notFound from "./en/pages/notFound.json"; +import GameStateCard from "./en/components/GameStateCard.json"; + import common from "./en/common.json"; export const resourcesEN = { @@ -43,6 +45,9 @@ export const resourcesEN = { forms: { gameForm, }, + components: { + GameStateCard, + }, common: common, }, } as const; diff --git a/src/client/locales/resources-ru.ts b/src/client/locales/resources-ru.ts index 7df6e7a..9100770 100644 --- a/src/client/locales/resources-ru.ts +++ b/src/client/locales/resources-ru.ts @@ -15,6 +15,8 @@ import sharedSaves from "./ru/pages/sharedSaves.json"; import publicSaves from "./ru/pages/publicSaves.json"; import notFound from "./ru/pages/notFound.json"; +import GameStateCard from "./ru/components/GameStateCard.json"; + import common from "./ru/common.json"; export const resourcesRU = { @@ -43,6 +45,9 @@ export const resourcesRU = { forms: { gameForm, }, + components: { + GameStateCard, + }, common: common, }, } as const; diff --git a/src/client/locales/ru/components/GameStateCard.json b/src/client/locales/ru/components/GameStateCard.json new file mode 100644 index 0000000..2b84097 --- /dev/null +++ b/src/client/locales/ru/components/GameStateCard.json @@ -0,0 +1,8 @@ +{ + "sync": "Синхронизация", + "every-hour": "Каждый час", + "every-day": "Каждый день", + "every-week": "Каждую неделю", + "every-month": "Каждый месяц", + "no": "Нет" +} diff --git a/src/client/locales/ru/pages/mySaves.json b/src/client/locales/ru/pages/mySaves.json index 4f668c6..ac01350 100644 --- a/src/client/locales/ru/pages/mySaves.json +++ b/src/client/locales/ru/pages/mySaves.json @@ -6,12 +6,6 @@ "home": "Главная", "choose-folder-to-list-files": "Выберите папку для отображения списка файлов", "upload": "Загрузить", - "sync": "Синхронизация", - "every-hour": "Каждый час", - "every-day": "Каждый день", - "every-week": "Каждую неделю", - "every-month": "Каждый месяц", - "no": "Нет", "local-saves": "Локальные сохранения", "folder": "Папка", "file": "Файл" diff --git a/src/client/pages/Games/components/CommonParametersWidget/CommonParameterForm/CommonParameterForm.tsx b/src/client/pages/Games/components/CommonParametersWidget/CommonParameterForm/CommonParameterForm.tsx index 34bd97a..3ccd5ce 100644 --- a/src/client/pages/Games/components/CommonParametersWidget/CommonParameterForm/CommonParameterForm.tsx +++ b/src/client/pages/Games/components/CommonParametersWidget/CommonParameterForm/CommonParameterForm.tsx @@ -98,7 +98,7 @@ export const CommonParameterForm = ({ }); return types.items.map((type) => ({ label: type.type, - value: type.id, + value: type.id.toString(), })); }} onBlur={() => field.onBlur()} diff --git a/src/client/pages/Games/components/GameForm/GameForm.tsx b/src/client/pages/Games/components/GameForm/GameForm.tsx index 4d18066..9894ae0 100644 --- a/src/client/pages/Games/components/GameForm/GameForm.tsx +++ b/src/client/pages/Games/components/GameForm/GameForm.tsx @@ -180,7 +180,7 @@ export const GameForm = (props: GameFormProps) => { pageSize: 25, }); return types.items.map((type) => ({ - value: type.id, + value: type.id.toString(), label: type.type, })); }} @@ -199,7 +199,7 @@ export const GameForm = (props: GameFormProps) => { pageSize: 25, }); return parameters.items.map((parameter) => ({ - value: parameter.id, + value: parameter.id.toString(), label: parameter.label, })); }} diff --git a/src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx b/src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx index b1c67c4..6b6bfe4 100644 --- a/src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx +++ b/src/client/pages/MySaves/LocalSaves/LocalSavesPage.tsx @@ -85,13 +85,19 @@ export const LocalSavesPage = () => { } }; - const uploadSave = async (folder: { + const uploadState = async (folder: { gameId?: string; + gameName?: string; path: string; name: string; }) => { try { - await gameStateAPI.uploadState(folder); + await gameStateAPI.uploadState({ + gameId: folder.gameId, + localPath: folder.path, + name: folder.gameName || folder.name, + isPublic: false, + }); } catch (e) { notify.error(e); } @@ -175,7 +181,7 @@ export const LocalSavesPage = () => { + { deleteState(gameState.id); @@ -240,68 +233,20 @@ export const MySavePage = () => {
- - + + {t("download")} +
); }; - -type ParametersViewProps = { - gameStateValues: GameStateValue[]; -}; - -const ParametersView = (props: ParametersViewProps) => { - return ( -
- {props.gameStateValues.map((field, idx) => ( - - ))} -
- ); -}; - -function formatTime(value: number, type: "seconds") { - if (type === "seconds") { - if (value < 60) { - return `${value} seconds`; - } - const minutes = Math.floor(value / 60); - - if (minutes < 60) { - return `${minutes} minutes`; - } - - const hours = Math.floor(minutes / 60); - - return `${hours} hours`; - } - - return `${value}`; -} - -const ParameterViewItem = (props: { - label: string; - value: string; - type: string; - description: string; -}) => { - if (props.type === "seconds") { - return ( -
- {props.label}:{" "} - - {formatTime(parseFloat(props.value), props.type)} - -
- ); - } - - return ( -
- {props.label}:{" "} - {props.value.toString()} -
- ); -}; 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 8e43c34..19f7e02 100644 --- a/src/client/pages/MySaves/MySave/my-save-page.module.scss +++ b/src/client/pages/MySaves/MySave/my-save-page.module.scss @@ -58,7 +58,3 @@ border: 1px solid var(--deco-color); border-radius: 6px; } - -.ParameterValue { - font-weight: bold; -} diff --git a/src/client/pages/MySaves/MySavesPage.tsx b/src/client/pages/MySaves/MySavesPage.tsx index ccd6145..fefc59a 100644 --- a/src/client/pages/MySaves/MySavesPage.tsx +++ b/src/client/pages/MySaves/MySavesPage.tsx @@ -3,19 +3,17 @@ import { useTranslation } from "react-i18next"; 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 { H1, H2 } from "@/client/ui/atoms/Typography"; import { Container } from "@/client/ui/atoms/Container/Container"; -import { ConfirmButton } from "@/client/ui/molecules/ConfirmButton/ConfirmButton"; -import { List } from "@/client/ui/molecules/List/List"; +import { CommonLink } from "@/client/ui/atoms/NavLink/CommonLink"; import { Paginator } from "@/client/ui/molecules/Paginator"; import { SearchForm } from "@/client/ui/molecules/SearchForm/SearchForm"; -import { CommonLink } from "@/client/ui/atoms/NavLink/CommonLink"; +import { Grid } from "@/client/ui/molecules/Grid"; +import { GameStateCard } from "@/client/lib/components/GameStateCard"; export const MySavesPage = () => { const { gameStateAPI } = useAPIContext(); @@ -53,43 +51,16 @@ export const MySavesPage = () => { onQueryChange={(searchQuery) => setQuery({ ...query, searchQuery })} /> - save.gameId} renderElement={(save) => ( - <> -
- {save.name} - -
- - {save.name} - - - {t("sync")}: {t(syncMap[save.sync])} - -
-
- -
- { - onDelete(save.id); - }} - color="danger" - > - {t("delete-save")}{" "} - -
- + )} /> diff --git a/src/client/pages/MySaves/my-saves-page.module.scss b/src/client/pages/MySaves/my-saves-page.module.scss index ded4d2f..15c2117 100644 --- a/src/client/pages/MySaves/my-saves-page.module.scss +++ b/src/client/pages/MySaves/my-saves-page.module.scss @@ -1,28 +1,3 @@ .SavesList { margin: 0.5rem 0; } - -.Buttons { - margin-left: auto; - display: flex; - justify-content: space-between; - 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/client/pages/PublicSaves/PublicSavesPage.tsx b/src/client/pages/saves/PublicSaves/PublicSavesPage.tsx similarity index 70% rename from src/client/pages/PublicSaves/PublicSavesPage.tsx rename to src/client/pages/saves/PublicSaves/PublicSavesPage.tsx index 53035bf..1e03fee 100644 --- a/src/client/pages/PublicSaves/PublicSavesPage.tsx +++ b/src/client/pages/saves/PublicSaves/PublicSavesPage.tsx @@ -6,12 +6,12 @@ import { useAPIContext } from "@/client/contexts/APIContext"; import { useResource } from "@/client/lib/hooks/useResource"; import { paths } from "@/client/config/paths"; -import { Link } from "wouter"; -import { H1, Paragraph } from "@/client/ui/atoms/Typography"; +import { H1 } from "@/client/ui/atoms/Typography"; import { Container } from "@/client/ui/atoms/Container/Container"; import { Paginator } from "@/client/ui/molecules/Paginator"; -import { List } from "@/client/ui/molecules/List/List"; import { SearchForm } from "@/client/ui/molecules/SearchForm/SearchForm"; +import { Grid } from "@/client/ui/molecules/Grid"; +import { GameStateCard } from "@/client/lib/components/GameStateCard"; export const PublicSavesPage = () => { const { gameStateAPI } = useAPIContext(); @@ -23,7 +23,7 @@ export const PublicSavesPage = () => { onSearch, loadResource: loadSaves, setQuery, - } = useResource(gameStateAPI.getSharedStates); + } = useResource(gameStateAPI.getPublicStates); return ( @@ -35,24 +35,15 @@ export const PublicSavesPage = () => { onQueryChange={(searchQuery) => setQuery({ ...query, searchQuery })} /> - save.gameId} renderElement={(save) => ( - <> -
- - {save.name} - - - {t("game-sync")} {save.sync} - -
- + )} /> diff --git a/src/client/pages/saves/PublicSaves/public-saves-page.module.scss b/src/client/pages/saves/PublicSaves/public-saves-page.module.scss new file mode 100644 index 0000000..15c2117 --- /dev/null +++ b/src/client/pages/saves/PublicSaves/public-saves-page.module.scss @@ -0,0 +1,3 @@ +.SavesList { + margin: 0.5rem 0; +} diff --git a/src/client/pages/saves/Save/SavePage.tsx b/src/client/pages/saves/Save/SavePage.tsx new file mode 100644 index 0000000..fb5a793 --- /dev/null +++ b/src/client/pages/saves/Save/SavePage.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "wouter"; + +import classes from "./save-page.module.scss"; + +import { GameState } from "@/types"; +import { useAPIContext } from "@/client/contexts/APIContext"; +import { useUIContext } from "@/client/contexts/UIContext"; + +import { H1, H2, Paragraph } from "@/client/ui/atoms/Typography"; +import { Bytes } from "@/client/ui/atoms/Bytes/Bytes"; +import { Container } from "@/client/ui/atoms/Container/Container"; +import { PolyButton } from "@/client/ui/molecules/PolyButton/PolyButton"; +import { ParametersView } from "@/client/lib/components/ParametersView/ParametersView"; + +export const SavePage = () => { + const { gameStateAPI } = useAPIContext(); + const { t } = useTranslation(undefined, { keyPrefix: "pages.mySave" }); + + const [gameState, setGameState] = useState(null); + const { notify } = useUIContext(); + + const { gameStateId } = useParams(); + useEffect(() => { + (async () => { + if (!gameStateId) return; + try { + const data = await gameStateAPI.getGameState(gameStateId); + setGameState(data); + } catch (error) { + notify.error(error); + setGameState(null); + } + })(); + }, []); + + if (!gameState || !gameStateId) { + return ( + +

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

+
+ ); + } + + const downloadState = async () => { + try { + 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); + } + }; + + return ( + +

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

+ +
+
+ + {t("path")}: {gameState?.localPath} + +
+
+ +

{t("about")}

+ + + +
+ + {t("size")}: + + + {t("uploaded-at")} {gameState.createdAt} + + +
+ + {t("download")} + +
+
+
+ ); +}; diff --git a/src/client/pages/saves/Save/save-page.module.scss b/src/client/pages/saves/Save/save-page.module.scss new file mode 100644 index 0000000..f3629b9 --- /dev/null +++ b/src/client/pages/saves/Save/save-page.module.scss @@ -0,0 +1,44 @@ +.SavePage { + padding-bottom: 1rem; + 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; + + .GameSaveSettingsLeft { + flex: 1; + } +} + +.Buttons { + margin-left: auto; + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.GameSaveArchive { + margin-top: 2rem; + display: flex; + gap: 0.5rem; + align-items: center; + padding: 1rem 1rem; + + border: 1px solid var(--deco-color); + border-radius: 6px; +} diff --git a/src/client/pages/SharedSaves/SharedSavesPage.tsx b/src/client/pages/saves/SharedSaves/SharedSavesPage.tsx similarity index 73% rename from src/client/pages/SharedSaves/SharedSavesPage.tsx rename to src/client/pages/saves/SharedSaves/SharedSavesPage.tsx index 7f978a8..128858e 100644 --- a/src/client/pages/SharedSaves/SharedSavesPage.tsx +++ b/src/client/pages/saves/SharedSaves/SharedSavesPage.tsx @@ -6,12 +6,12 @@ import { useAPIContext } from "@/client/contexts/APIContext"; import { useResource } from "@/client/lib/hooks/useResource"; import { paths } from "@/client/config/paths"; -import { Link } from "wouter"; -import { H1, Paragraph } from "@/client/ui/atoms/Typography"; +import { H1 } from "@/client/ui/atoms/Typography"; import { Container } from "@/client/ui/atoms/Container/Container"; import { SearchForm } from "@/client/ui/molecules/SearchForm/SearchForm"; -import { List } from "@/client/ui/molecules/List/List"; import { Paginator } from "@/client/ui/molecules/Paginator"; +import { Grid } from "@/client/ui/molecules/Grid"; +import { GameStateCard } from "@/client/lib/components/GameStateCard"; export const SharedSavesPage = () => { const { gameStateAPI } = useAPIContext(); @@ -35,24 +35,15 @@ export const SharedSavesPage = () => { onQueryChange={(searchQuery) => setQuery({ ...query, searchQuery })} /> - save.gameId} renderElement={(save) => ( - <> -
- - {save.name} - - - {t("game-sync")} {save.sync} - -
- + )} /> diff --git a/src/client/pages/saves/SharedSaves/shared-saves-page.module.scss b/src/client/pages/saves/SharedSaves/shared-saves-page.module.scss new file mode 100644 index 0000000..15c2117 --- /dev/null +++ b/src/client/pages/saves/SharedSaves/shared-saves-page.module.scss @@ -0,0 +1,3 @@ +.SavesList { + margin: 0.5rem 0; +} diff --git a/src/client/ui/atoms/Button/button.module.scss b/src/client/ui/atoms/Button/button.module.scss index e0629be..9eceab5 100644 --- a/src/client/ui/atoms/Button/button.module.scss +++ b/src/client/ui/atoms/Button/button.module.scss @@ -1,15 +1,15 @@ .BaseButton { display: inline-block; background-color: var(--accent-color); - padding: 11px 12px 11px; + padding: 0.25rem 0.5rem; border-radius: 6px; border: 2px solid transparent; outline: none; cursor: pointer; text-decoration: none; text-transform: uppercase; + font-size: 0.75rem; font-family: inherit; - font-size: 1rem; font-weight: 700; line-height: 1; color: #fff; @@ -19,9 +19,6 @@ -moz-osx-font-smoothing: grayscale; position: relative; - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - &:hover:not(:disabled) { background-color: var(--accent-hover-color); } diff --git a/src/client/ui/molecules/ConfirmButton/confirm-button.module.scss b/src/client/ui/hooks/useConfirmModal/confirm-modal.module.scss similarity index 100% rename from src/client/ui/molecules/ConfirmButton/confirm-button.module.scss rename to src/client/ui/hooks/useConfirmModal/confirm-modal.module.scss diff --git a/src/client/ui/hooks/useConfirmModal/useConfirmModal.tsx b/src/client/ui/hooks/useConfirmModal/useConfirmModal.tsx new file mode 100644 index 0000000..632b600 --- /dev/null +++ b/src/client/ui/hooks/useConfirmModal/useConfirmModal.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; + +import classes from "./confirm-modal.module.scss"; + +import { Modal } from "../../molecules/Modal/Modal"; +import { Button } from "../../atoms/Button/Button"; + +export type UseConfirmModalArgs = { + onConfirm?: () => void; + prompt?: string; +}; + +export const useConfirmModal = (args: UseConfirmModalArgs) => { + const [isOpen, setIsOpen] = useState(false); + + const onClick = () => { + if (!args.onConfirm) { + return; + } + + setIsOpen(true); + }; + + const modal = ( + setIsOpen(false)} + showCloseButton={false} + headerClassName={classes.ModalHeader} + bodyClassName={classes.ModalBody} + > +
+ + +
+
+ ); + + return { + isOpen, + onClick, + modal, + }; +}; diff --git a/src/client/ui/molecules/ConfirmButton/ConfirmButton.tsx b/src/client/ui/molecules/ConfirmButton/ConfirmButton.tsx index a95f558..c8c12f5 100644 --- a/src/client/ui/molecules/ConfirmButton/ConfirmButton.tsx +++ b/src/client/ui/molecules/ConfirmButton/ConfirmButton.tsx @@ -1,9 +1,5 @@ -import { useState } from "react"; - -import classes from "./confirm-button.module.scss"; - import { Button, ButtonProps } from "@/client/ui/atoms/Button/Button"; -import { Modal } from "../Modal/Modal"; +import { useConfirmModal } from "../../hooks/useConfirmModal/useConfirmModal"; export type ConfirmButtonProps = Omit< ButtonProps, @@ -14,47 +10,15 @@ export type ConfirmButtonProps = Omit< }; export const ConfirmButton = (props: ConfirmButtonProps) => { - const [isOpen, setIsOpen] = useState(false); - - const onClick = () => { - if (!props.onClick) { - return; - } - - setIsOpen(true); - }; + const { onClick, modal } = useConfirmModal({ + onConfirm: props.onClick, + prompt: props.prompt, + }); return ( <> - - - + {modal} ); }; diff --git a/src/client/ui/molecules/Grid/Grid.tsx b/src/client/ui/molecules/Grid/Grid.tsx new file mode 100644 index 0000000..9be4ed0 --- /dev/null +++ b/src/client/ui/molecules/Grid/Grid.tsx @@ -0,0 +1,26 @@ +import { clsx } from "clsx"; + +import classes from "./grid.module.scss"; + +export type GridProps = { + elements: E[]; + renderElement: (element: E) => JSX.Element; + getKey: (element: E) => string; + className?: string; + elementClassName?: string; +}; + +export function Grid(props: GridProps) { + return ( +
    + {props.elements.map((element) => ( +
  • + {props.renderElement(element)} +
  • + ))} +
+ ); +} diff --git a/src/client/ui/molecules/Grid/grid.module.scss b/src/client/ui/molecules/Grid/grid.module.scss new file mode 100644 index 0000000..97beca9 --- /dev/null +++ b/src/client/ui/molecules/Grid/grid.module.scss @@ -0,0 +1,12 @@ +.Grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; + align-items: center; + list-style: none; +} + +.GridElement { + border: 1px solid var(--deco-color); + border-radius: 6px; +} diff --git a/src/client/ui/molecules/Grid/index.ts b/src/client/ui/molecules/Grid/index.ts new file mode 100644 index 0000000..f2b8147 --- /dev/null +++ b/src/client/ui/molecules/Grid/index.ts @@ -0,0 +1 @@ +export * from "./Grid"; diff --git a/src/client/ui/molecules/PolyButton/PolyButton.tsx b/src/client/ui/molecules/PolyButton/PolyButton.tsx new file mode 100644 index 0000000..d5a9c8b --- /dev/null +++ b/src/client/ui/molecules/PolyButton/PolyButton.tsx @@ -0,0 +1,66 @@ +import { useRef, useState } from "react"; +import { clsx } from "clsx"; + +import classes from "./poly-button.module.scss"; +import { useOnClickOutside } from "../../hooks/useOnClickOutside"; + +export type PolyButtonProps = { + className?: string; + onClick: () => void; + children: React.ReactNode; + subActions: { + onClick: () => void; + children: React.ReactNode; + key: string; + }[]; +}; + +export const PolyButton = (props: PolyButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + + const menuRef = useRef(null); + + useOnClickOutside(menuRef, () => setIsOpen(false)); + + return ( +
+
+ + + +
+ + {isOpen && ( +
    + {props.subActions.map((subAction) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/client/pages/SharedSaves/shared-saves-page.module.scss b/src/client/ui/molecules/PolyButton/index.ts similarity index 100% rename from src/client/pages/SharedSaves/shared-saves-page.module.scss rename to src/client/ui/molecules/PolyButton/index.ts diff --git a/src/client/ui/molecules/PolyButton/poly-button.module.scss b/src/client/ui/molecules/PolyButton/poly-button.module.scss new file mode 100644 index 0000000..f808ec3 --- /dev/null +++ b/src/client/ui/molecules/PolyButton/poly-button.module.scss @@ -0,0 +1,147 @@ +.PolyButton { + position: relative; +} + +.PolyButtonBlock { + display: flex; + align-items: stretch; +} + +.PolyButtonButton { + display: inline-block; + background-color: var(--accent-color); + padding: 0.25rem 0.5rem; + border-radius: 6px 0 0 6px; + border: 2px solid transparent; + border-right: 2px solid var(--accent-hover-color); + outline: none; + cursor: pointer; + text-decoration: none; + text-transform: uppercase; + font-family: inherit; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; + color: #fff; + white-space: nowrap; + transition: background-color 0.3s ease; + position: relative; + + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + + &:hover:not(:disabled) { + background-color: var(--accent-hover-color); + } + &:active:not(:disabled) { + background-color: var(--accent-color); + } + + &:disabled { + opacity: 0.5; + } + + &:focus-visible { + border-color: var(--deco-color); + } +} + +.PolyButtonMenuButton { + display: inline-block; + width: 1.5rem; + padding: 0.25rem 0.5rem; + background-color: var(--accent-color); + border-radius: 0 6px 6px 0; + border: 2px solid transparent; + outline: none; + cursor: pointer; + text-decoration: none; + text-transform: uppercase; + + transition: background-color 0.3s ease; + position: relative; + + &:hover:not(:disabled) { + background-color: var(--accent-hover-color); + } + &:active:not(:disabled) { + background-color: var(--accent-color); + } + + &:disabled { + opacity: 0.5; + } + + &:focus-visible { + border-color: var(--deco-color); + } + + // arrows + & > div { + position: absolute; + width: 10px; + height: 3px; + background-color: #fff; + border-radius: 2px; + transition: transform 0.15s ease-in-out; + pointer-events: none; + } + & > div:nth-child(1) { + top: 50%; + left: calc(50% - 2px); + transform: translateY(-50%) rotate(45deg); + } + & > div:nth-child(2) { + top: 50%; + right: calc(50% - 2px); + transform: translateY(-50%) rotate(-45deg); + } + + &[data-is-open="true"] > div:nth-child(1) { + transform: translateY(-50%) rotate(-45deg); + } + &[data-is-open="true"] > div:nth-child(2) { + transform: translateY(-50%) rotate(45deg); + } +} + +.PolyButtonSubActions { + list-style: none; + position: absolute; + top: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + width: max-content; + min-width: 100%; + background-color: var(--accent-color); + border-radius: 6px; +} + +.PolyButtonSubAction { + display: block; + + & > button { + display: block; + width: 100%; + padding: 0.25rem 0.5rem; + background-color: transparent; + border: 2px solid transparent; + border-radius: 6px; + outline: none; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; + color: #fff; + cursor: pointer; + transition: background-color 0.3s ease; + text-transform: uppercase; + + &:hover { + background-color: var(--accent-hover-color); + } + } + + &:not(:first-child) { + border-top: 2px solid var(--accent-hover-color); + } +} diff --git a/src/client/ui/molecules/ThreeDotsMenu/ThreeDotsMenu.tsx b/src/client/ui/molecules/ThreeDotsMenu/ThreeDotsMenu.tsx new file mode 100644 index 0000000..6834446 --- /dev/null +++ b/src/client/ui/molecules/ThreeDotsMenu/ThreeDotsMenu.tsx @@ -0,0 +1,55 @@ +import { clsx } from "clsx"; +import classes from "./three-dots-menu.module.scss"; +import { ReactNode, useRef, useState } from "react"; +import { useOnClickOutside } from "../../hooks/useOnClickOutside"; + +export type ThreeDotsMenuProps = { + className?: string; + menuItems: { + onClick?: () => void; + children: ReactNode; + key: string; + }[]; +}; + +export const ThreeDotsMenu = (props: ThreeDotsMenuProps) => { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useOnClickOutside(menuRef, () => setIsOpen(false)); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + + + {isOpen && ( +
    + {props.menuItems.map((item) => ( +
  • + {item.children} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/client/ui/molecules/ThreeDotsMenu/index.ts b/src/client/ui/molecules/ThreeDotsMenu/index.ts new file mode 100644 index 0000000..ab3b398 --- /dev/null +++ b/src/client/ui/molecules/ThreeDotsMenu/index.ts @@ -0,0 +1 @@ +export * from "./ThreeDotsMenu"; diff --git a/src/client/ui/molecules/ThreeDotsMenu/three-dots-menu.module.scss b/src/client/ui/molecules/ThreeDotsMenu/three-dots-menu.module.scss new file mode 100644 index 0000000..5c067cd --- /dev/null +++ b/src/client/ui/molecules/ThreeDotsMenu/three-dots-menu.module.scss @@ -0,0 +1,58 @@ +.ThreeDotsMenu { + position: relative; +} + +.ThreeDotsMenuButton { + width: 2.5rem; + height: 1rem; + display: flex; + align-items: center; + justify-content: space-evenly; + background-color: transparent; + border-radius: 6px; + border: none; + cursor: pointer; + outline: none; + transition: background-color 0.3s ease; + + & > div { + width: 0.3rem; + height: 0.3rem; + background-color: white; + border-radius: 50%; + transition: background-color 0.3s ease; + } + + &:hover { + background-color: rgba(170, 170, 173, 0.6); + } +} + +.ThreeDotsMenuContent { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.25rem; + background-color: black; + color: white; + border-radius: 6px; + border: 1px solid var(--deco-color); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + font-size: 0.875rem; + line-height: 1.25rem; + overflow: hidden; + + .ThreeDotsMenuItem { + padding: 0.25rem 0.5rem; + cursor: pointer; + transition: background-color 0.3s ease; + + &:not(:first-child) { + border-top: 1px solid var(--deco-color); + } + + &:hover { + background-color: rgba(255, 255, 255, 0.2); + } + } +} diff --git a/src/preload.ts b/src/preload.ts index 9b0f43c..639c6d2 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -11,18 +11,20 @@ const electronApi: Window["electronAPI"] = { showFolderDialog: () => ipcRenderer.invoke("showFolderDialog"), - getSavePaths: (paths) => ipcRenderer.invoke("getSavePaths", paths), + getStatePaths: (paths) => ipcRenderer.invoke("getStatePaths", paths), getFolderInfo: (folderPath) => ipcRenderer.invoke("getFolderInfo", folderPath), - uploadSave: (folder, game) => ipcRenderer.invoke("uploadSave", folder, game), - - onGetSyncedSaves: (callback) => { - ipcRenderer.on("getSyncedSaves", callback); + onGetSyncedStates: (callback) => { + ipcRenderer.on("getSyncedStates", callback); }, - sendSyncedSaves: (args) => ipcRenderer.invoke("sendSyncedSaves", args), + sendSyncedStates: (args) => ipcRenderer.invoke("sendSyncedStates", args), + + uploadState: (folder) => ipcRenderer.invoke("uploadState", folder), + + reuploadState: (gameState) => ipcRenderer.invoke("reuploadState", gameState), downloadState: (gameState) => ipcRenderer.invoke("downloadState", gameState),