diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index dacbe7a99..a6ed6986c 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -87,6 +87,7 @@ def get_supported_platforms(request: Request) -> list[PlatformSchema]: "rom_count": 0, "created_at": now, "updated_at": now, + "fs_size_bytes": 0, } if platform["name"] in db_platforms_map: diff --git a/backend/endpoints/responses/assets.py b/backend/endpoints/responses/assets.py index 5a326c80f..1a8d88de6 100644 --- a/backend/endpoints/responses/assets.py +++ b/backend/endpoints/responses/assets.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import TypedDict from .base import BaseModel @@ -27,27 +26,11 @@ class ScreenshotSchema(BaseAsset): pass -class UploadedScreenshotsResponse(TypedDict): - uploaded: int - screenshots: list[ScreenshotSchema] - merged_screenshots: list[str] - - class SaveSchema(BaseAsset): emulator: str | None screenshot: ScreenshotSchema | None -class UploadedSavesResponse(TypedDict): - uploaded: int - saves: list[SaveSchema] - - class StateSchema(BaseAsset): emulator: str | None screenshot: ScreenshotSchema | None - - -class UploadedStatesResponse(TypedDict): - uploaded: int - states: list[StateSchema] diff --git a/backend/endpoints/responses/platform.py b/backend/endpoints/responses/platform.py index cae9a9a22..9e0068912 100644 --- a/backend/endpoints/responses/platform.py +++ b/backend/endpoints/responses/platform.py @@ -29,7 +29,7 @@ class PlatformSchema(BaseModel): aspect_ratio: str = DEFAULT_COVER_ASPECT_RATIO created_at: datetime updated_at: datetime - filesystem_size_bytes: int + fs_size_bytes: int class Config: from_attributes = True diff --git a/backend/endpoints/saves.py b/backend/endpoints/saves.py index 2f520ae7d..19b2e7830 100644 --- a/backend/endpoints/saves.py +++ b/backend/endpoints/saves.py @@ -1,14 +1,13 @@ from datetime import datetime, timezone from decorators.auth import protected_route -from endpoints.responses import MessageResponse -from endpoints.responses.assets import SaveSchema, UploadedSavesResponse +from endpoints.responses.assets import SaveSchema from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException -from fastapi import File, HTTPException, Request, UploadFile, status +from fastapi import HTTPException, Request, UploadFile, status from handler.auth.constants import Scope from handler.database import db_rom_handler, db_save_handler, db_screenshot_handler from handler.filesystem import fs_asset_handler -from handler.scan_handler import scan_save +from handler.scan_handler import scan_save, scan_screenshot from logger.logger import log from utils.router import APIRouter @@ -19,123 +18,159 @@ router = APIRouter( @protected_route(router.post, "", [Scope.ASSETS_WRITE]) -def add_saves( +async def add_save( request: Request, rom_id: int, - saves: list[UploadFile] = File(...), # noqa: B008 emulator: str | None = None, -) -> UploadedSavesResponse: +) -> SaveSchema: + data = await request.form() + rom = db_rom_handler.get_rom(rom_id) if not rom: raise RomNotFoundInDatabaseException(rom_id) - current_user = request.user - log.info(f"Uploading saves to {rom.name}") + log.info(f"Uploading save of {rom.name}") saves_path = fs_asset_handler.build_saves_file_path( user=request.user, platform_fs_slug=rom.platform.fs_slug, emulator=emulator ) - for save in saves: - if not save.filename: - log.error("Save file has no filename") - continue - - fs_asset_handler.write_file(file=save, path=saves_path) - - # Scan or update save - scanned_save = scan_save( - file_name=save.filename, - user=request.user, - platform_fs_slug=rom.platform.fs_slug, - emulator=emulator, + if "saveFile" not in data: + log.error("No save file provided") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="No save file provided" ) - db_save = db_save_handler.get_save_by_filename( - rom_id=rom.id, user_id=current_user.id, file_name=save.filename - ) - if db_save: - db_save_handler.update_save( - db_save.id, {"file_size_bytes": scanned_save.file_size_bytes} - ) - continue + saveFile: UploadFile = data["saveFile"] # type: ignore + if not saveFile.filename: + log.error("Save file has no filename") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Save file has no filename" + ) + + fs_asset_handler.write_file(file=saveFile, path=saves_path) + + # Scan or update save + scanned_save = scan_save( + file_name=saveFile.filename, + user=request.user, + platform_fs_slug=rom.platform.fs_slug, + emulator=emulator, + ) + db_save = db_save_handler.get_save_by_filename( + user_id=request.user.id, rom_id=rom.id, file_name=saveFile.filename + ) + if db_save: + db_save = db_save_handler.update_save( + db_save.id, {"file_size_bytes": scanned_save.file_size_bytes} + ) + else: scanned_save.rom_id = rom.id - scanned_save.user_id = current_user.id + scanned_save.user_id = request.user.id scanned_save.emulator = emulator - db_save_handler.add_save(scanned_save) + db_save = db_save_handler.add_save(save=scanned_save) - # Set the last played time for the current user - rom_user = db_rom_handler.get_rom_user(rom.id, current_user.id) - if not rom_user: - rom_user = db_rom_handler.add_rom_user(rom.id, current_user.id) - db_rom_handler.update_rom_user( - rom_user.id, {"last_played": datetime.now(timezone.utc)} + screenshotFile: UploadFile | None = data.get("screenshotFile", None) # type: ignore + if screenshotFile and screenshotFile.filename: + screenshots_path = fs_asset_handler.build_screenshots_file_path( + user=request.user, platform_fs_slug=rom.platform_slug ) + fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path) + + # Scan or update screenshot + scanned_screenshot = scan_screenshot( + file_name=screenshotFile.filename, + user=request.user, + platform_fs_slug=rom.platform_slug, + ) + db_screenshot = db_screenshot_handler.get_screenshot_by_filename( + rom_id=rom.id, user_id=request.user.id, file_name=screenshotFile.filename + ) + if db_screenshot: + db_screenshot = db_screenshot_handler.update_screenshot( + db_screenshot.id, + {"file_size_bytes": scanned_screenshot.file_size_bytes}, + ) + else: + scanned_screenshot.rom_id = rom.id + scanned_screenshot.user_id = request.user.id + db_screenshot = db_screenshot_handler.add_screenshot( + screenshot=scanned_screenshot + ) + + # Set the last played time for the current user + rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=request.user.id) + if not rom_user: + rom_user = db_rom_handler.add_rom_user(rom_id=rom.id, user_id=request.user.id) + db_rom_handler.update_rom_user( + rom_user.id, {"last_played": datetime.now(timezone.utc)} + ) + # Refetch the rom to get updated saves rom = db_rom_handler.get_rom(rom_id) if not rom: raise RomNotFoundInDatabaseException(rom_id) - return { - "uploaded": len(saves), - "saves": [ - SaveSchema.model_validate(s) - for s in rom.saves - if s.user_id == current_user.id - ], - } + return SaveSchema.model_validate(db_save) -# @protected_route(router.get, "", [Scope.ASSETS_READ]) -# def get_saves(request: Request) -> MessageResponse: -# pass +@protected_route(router.get, "", [Scope.ASSETS_READ]) +def get_saves( + request: Request, rom_id: int | None = None, platform_id: int | None = None +) -> list[SaveSchema]: + saves = db_save_handler.get_saves( + user_id=request.user.id, rom_id=rom_id, platform_id=platform_id + ) + + return [SaveSchema.model_validate(save) for save in saves] -# @protected_route(router.get, "/{id}", [Scope.ASSETS_READ]) -# def get_save(request: Request, id: int) -> MessageResponse: -# pass +@protected_route(router.get, "/{id}", [Scope.ASSETS_READ]) +def get_save(request: Request, id: int) -> SaveSchema: + save = db_save_handler.get_save(user_id=request.user.id, id=id) + + if not save: + error = f"Save with ID {id} not found" + log.error(error) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) + + return SaveSchema.model_validate(save) @protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE]) async def update_save(request: Request, id: int) -> SaveSchema: data = await request.form() - db_save = db_save_handler.get_save(id) + db_save = db_save_handler.get_save(user_id=request.user.id, id=id) if not db_save: error = f"Save with ID {id} not found" log.error(error) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) - if db_save.user_id != request.user.id: - error = "You are not authorized to update this save" - log.error(error) - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error) - - if "file" in data: - file: UploadFile = data["file"] # type: ignore - fs_asset_handler.write_file(file=file, path=db_save.file_path) - db_save_handler.update_save(db_save.id, {"file_size_bytes": file.size}) + if "saveFile" in data: + saveFile: UploadFile = data["saveFile"] # type: ignore + fs_asset_handler.write_file(file=saveFile, path=db_save.file_path) + db_save = db_save_handler.update_save( + db_save.id, {"file_size_bytes": saveFile.size} + ) # Set the last played time for the current user - current_user = request.user - rom_user = db_rom_handler.get_rom_user(db_save.rom_id, current_user.id) + rom_user = db_rom_handler.get_rom_user(db_save.rom_id, request.user.id) if not rom_user: - rom_user = db_rom_handler.add_rom_user(db_save.rom_id, current_user.id) + rom_user = db_rom_handler.add_rom_user(db_save.rom_id, request.user.id) db_rom_handler.update_rom_user( rom_user.id, {"last_played": datetime.now(timezone.utc)} ) # Refetch the save to get updated fields - db_save = db_save_handler.get_save(id) return SaveSchema.model_validate(db_save) @protected_route(router.post, "/delete", [Scope.ASSETS_WRITE]) -async def delete_saves(request: Request) -> MessageResponse: +async def delete_saves(request: Request) -> list[int]: data: dict = await request.json() save_ids: list = data["saves"] - delete_from_fs: list = data["delete_from_fs"] if not save_ids: error = "No saves were provided" @@ -143,44 +178,33 @@ async def delete_saves(request: Request) -> MessageResponse: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) for save_id in save_ids: - save = db_save_handler.get_save(save_id) + save = db_save_handler.get_save(user_id=request.user.id, id=save_id) if not save: error = f"Save with ID {save_id} not found" log.error(error) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) - if save.user_id != request.user.id: - error = "You are not authorized to delete this save" - log.error(error) - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error) - db_save_handler.delete_save(save_id) - if save_id in delete_from_fs: - log.info(f"Deleting {save.file_name} from filesystem") - - try: - fs_asset_handler.remove_file( - file_name=save.file_name, file_path=save.file_path - ) - except FileNotFoundError as exc: - error = f"Save file {save.file_name} not found for platform {save.rom.platform_slug}" - log.error(error) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=error - ) from exc + log.info(f"Deleting {save.file_name} from filesystem") + try: + fs_asset_handler.remove_file( + file_name=save.file_name, file_path=save.file_path + ) + except FileNotFoundError: + error = f"Save file {save.file_name} not found for platform {save.rom.platform_slug}" + log.error(error) if save.screenshot: db_screenshot_handler.delete_screenshot(save.screenshot.id) - if delete_from_fs: - try: - fs_asset_handler.remove_file( - file_name=save.screenshot.file_name, - file_path=save.screenshot.file_path, - ) - except FileNotFoundError: - error = f"Screenshot file {save.screenshot.file_name} not found for save {save.file_name}" - log.error(error) + try: + fs_asset_handler.remove_file( + file_name=save.screenshot.file_name, + file_path=save.screenshot.file_path, + ) + except FileNotFoundError: + error = f"Screenshot file {save.screenshot.file_name} not found for save {save.file_name}" + log.error(error) - return {"msg": f"Successfully deleted {len(save_ids)} saves"} + return save_ids diff --git a/backend/endpoints/screenshots.py b/backend/endpoints/screenshots.py index 616ee6a6d..d35fa4a4e 100644 --- a/backend/endpoints/screenshots.py +++ b/backend/endpoints/screenshots.py @@ -1,7 +1,7 @@ from decorators.auth import protected_route -from endpoints.responses.assets import ScreenshotSchema, UploadedScreenshotsResponse +from endpoints.responses.assets import ScreenshotSchema from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException -from fastapi import File, Request, UploadFile +from fastapi import HTTPException, Request, UploadFile, status from handler.auth.constants import Scope from handler.database import db_rom_handler, db_screenshot_handler from handler.filesystem import fs_asset_handler @@ -16,12 +16,13 @@ router = APIRouter( @protected_route(router.post, "", [Scope.ASSETS_WRITE]) -def add_screenshots( +async def add_screenshot( request: Request, rom_id: int, - screenshots: list[UploadFile] = File(...), # noqa: B008 -) -> UploadedScreenshotsResponse: - rom = db_rom_handler.get_rom(rom_id) +) -> ScreenshotSchema: + data = await request.form() + + rom = db_rom_handler.get_rom(id=rom_id) if not rom: raise RomNotFoundInDatabaseException(rom_id) @@ -32,43 +33,52 @@ def add_screenshots( user=request.user, platform_fs_slug=rom.platform_slug ) - for screenshot in screenshots: - if not screenshot.filename: - log.warning("Skipping empty screenshot") - continue - - fs_asset_handler.write_file(file=screenshot, path=screenshots_path) - - # Scan or update screenshot - scanned_screenshot = scan_screenshot( - file_name=screenshot.filename, - user=request.user, - platform_fs_slug=rom.platform_slug, + if "screenshotFile" not in data: + log.error("No screenshot file provided") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No screenshot file provided", ) - db_screenshot = db_screenshot_handler.get_screenshot_by_filename( - rom_id=rom.id, user_id=current_user.id, file_name=screenshot.filename - ) - if db_screenshot: - db_screenshot_handler.update_screenshot( - db_screenshot.id, - {"file_size_bytes": scanned_screenshot.file_size_bytes}, - ) - continue + screenshotFile: UploadFile = data["screenshotFile"] # type: ignore + if not screenshotFile.filename: + log.error("Screenshot file has no filename") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Screenshot file has no filename", + ) + + if not screenshotFile.filename: + log.warning("Skipping empty screenshot") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Screenshot has no filename" + ) + + fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path) + + # Scan or update screenshot + scanned_screenshot = scan_screenshot( + file_name=screenshotFile.filename, + user=request.user, + platform_fs_slug=rom.platform_slug, + ) + db_screenshot = db_screenshot_handler.get_screenshot_by_filename( + rom_id=rom.id, user_id=current_user.id, file_name=screenshotFile.filename + ) + if db_screenshot: + db_screenshot = db_screenshot_handler.update_screenshot( + db_screenshot.id, + {"file_size_bytes": scanned_screenshot.file_size_bytes}, + ) + else: scanned_screenshot.rom_id = rom.id scanned_screenshot.user_id = current_user.id - db_screenshot_handler.add_screenshot(scanned_screenshot) + db_screenshot = db_screenshot_handler.add_screenshot( + screenshot=scanned_screenshot + ) - rom = db_rom_handler.get_rom(rom_id) + rom = db_rom_handler.get_rom(id=rom_id) if not rom: raise RomNotFoundInDatabaseException(rom_id) - return { - "uploaded": len(screenshots), - "screenshots": [ - ScreenshotSchema.model_validate(s) - for s in rom.screenshots - if s.user_id == current_user.id - ], - "merged_screenshots": rom.merged_screenshots, - } + return ScreenshotSchema.model_validate(db_screenshot) diff --git a/backend/endpoints/states.py b/backend/endpoints/states.py index 9767a9b52..352018ff3 100644 --- a/backend/endpoints/states.py +++ b/backend/endpoints/states.py @@ -1,14 +1,13 @@ from datetime import datetime, timezone from decorators.auth import protected_route -from endpoints.responses import MessageResponse -from endpoints.responses.assets import StateSchema, UploadedStatesResponse +from endpoints.responses.assets import StateSchema from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException -from fastapi import File, HTTPException, Request, UploadFile, status +from fastapi import HTTPException, Request, UploadFile, status from handler.auth.constants import Scope from handler.database import db_rom_handler, db_screenshot_handler, db_state_handler from handler.filesystem import fs_asset_handler -from handler.scan_handler import scan_state +from handler.scan_handler import scan_screenshot, scan_state from logger.logger import log from utils.router import APIRouter @@ -19,121 +18,159 @@ router = APIRouter( @protected_route(router.post, "", [Scope.ASSETS_WRITE]) -def add_states( +async def add_state( request: Request, rom_id: int, - states: list[UploadFile] = File(...), # noqa: B008 emulator: str | None = None, -) -> UploadedStatesResponse: +) -> StateSchema: + data = await request.form() + rom = db_rom_handler.get_rom(rom_id) if not rom: raise RomNotFoundInDatabaseException(rom_id) - current_user = request.user - log.info(f"Uploading states to {rom.name}") + log.info(f"Uploading state of {rom.name}") states_path = fs_asset_handler.build_states_file_path( user=request.user, platform_fs_slug=rom.platform.fs_slug, emulator=emulator ) - for state in states: - if not state.filename: - log.warning("Skipping file with no filename") - continue - - fs_asset_handler.write_file(file=state, path=states_path) - - # Scan or update state - scanned_state = scan_state( - file_name=state.filename, - user=request.user, - platform_fs_slug=rom.platform.fs_slug, - emulator=emulator, + if "stateFile" not in data: + log.error("No state file provided") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="No state file provided" ) - db_state = db_state_handler.get_state_by_filename( - rom_id=rom.id, user_id=current_user.id, file_name=state.filename - ) - if db_state: - db_state_handler.update_state( - db_state.id, {"file_size_bytes": scanned_state.file_size_bytes} - ) - continue + stateFile: UploadFile = data["stateFile"] # type: ignore + if not stateFile.filename: + log.error("State file has no filename") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="State file has no filename" + ) + + fs_asset_handler.write_file(file=stateFile, path=states_path) + + # Scan or update state + scanned_state = scan_state( + file_name=stateFile.filename, + user=request.user, + platform_fs_slug=rom.platform.fs_slug, + emulator=emulator, + ) + db_state = db_state_handler.get_state_by_filename( + user_id=request.user.id, rom_id=rom.id, file_name=stateFile.filename + ) + if db_state: + db_state = db_state_handler.update_state( + db_state.id, {"file_size_bytes": scanned_state.file_size_bytes} + ) + else: scanned_state.rom_id = rom.id - scanned_state.user_id = current_user.id + scanned_state.user_id = request.user.id scanned_state.emulator = emulator - db_state_handler.add_state(scanned_state) + db_state = db_state_handler.add_state(state=scanned_state) - # Set the last played time for the current user - rom_user = db_rom_handler.get_rom_user(rom.id, current_user.id) - if not rom_user: - rom_user = db_rom_handler.add_rom_user(rom.id, current_user.id) - db_rom_handler.update_rom_user( - rom_user.id, {"last_played": datetime.now(timezone.utc)} + screenshotFile: UploadFile | None = data.get("screenshotFile", None) # type: ignore + if screenshotFile and screenshotFile.filename: + screenshots_path = fs_asset_handler.build_screenshots_file_path( + user=request.user, platform_fs_slug=rom.platform_slug ) + fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path) + + # Scan or update screenshot + scanned_screenshot = scan_screenshot( + file_name=screenshotFile.filename, + user=request.user, + platform_fs_slug=rom.platform_slug, + ) + db_screenshot = db_screenshot_handler.get_screenshot_by_filename( + rom_id=rom.id, user_id=request.user.id, file_name=screenshotFile.filename + ) + if db_screenshot: + db_screenshot = db_screenshot_handler.update_screenshot( + db_screenshot.id, + {"file_size_bytes": scanned_screenshot.file_size_bytes}, + ) + else: + scanned_screenshot.rom_id = rom.id + scanned_screenshot.user_id = request.user.id + db_screenshot = db_screenshot_handler.add_screenshot( + screenshot=scanned_screenshot + ) + + # Set the last played time for the current user + rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=request.user.id) + if not rom_user: + rom_user = db_rom_handler.add_rom_user(rom_id=rom.id, user_id=request.user.id) + db_rom_handler.update_rom_user( + rom_user.id, {"last_played": datetime.now(timezone.utc)} + ) + + # Refetch the rom to get updated states rom = db_rom_handler.get_rom(rom_id) if not rom: raise RomNotFoundInDatabaseException(rom_id) - return { - "uploaded": len(states), - "states": [ - StateSchema.model_validate(s) - for s in rom.states - if s.user_id == current_user.id - ], - } + return StateSchema.model_validate(db_state) -# @protected_route(router.get, "", [Scope.ASSETS_READ]) -# def get_states(request: Request) -> MessageResponse: -# pass +@protected_route(router.get, "", [Scope.ASSETS_READ]) +def get_states( + request: Request, rom_id: int | None = None, platform_id: int | None = None +) -> list[StateSchema]: + states = db_state_handler.get_states( + user_id=request.user.id, rom_id=rom_id, platform_id=platform_id + ) + + return [StateSchema.model_validate(state) for state in states] -# @protected_route(router.get, "/{id}", [Scope.ASSETS_READ]) -# def get_state(request: Request, id: int) -> MessageResponse: -# pass +@protected_route(router.get, "/{id}", [Scope.ASSETS_READ]) +def get_state(request: Request, id: int) -> StateSchema: + state = db_state_handler.get_state(user_id=request.user.id, id=id) + + if not state: + error = f"State with ID {id} not found" + log.error(error) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) + + return StateSchema.model_validate(state) @protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE]) async def update_state(request: Request, id: int) -> StateSchema: data = await request.form() - db_state = db_state_handler.get_state(id) + db_state = db_state_handler.get_state(user_id=request.user.id, id=id) if not db_state: - error = f"Save with ID {id} not found" + error = f"State with ID {id} not found" log.error(error) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) - if db_state.user_id != request.user.id: - error = "You are not authorized to update this save state" - log.error(error) - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error) - - if "file" in data: - file: UploadFile = data["file"] # type: ignore - fs_asset_handler.write_file(file=file, path=db_state.file_path) - db_state_handler.update_state(db_state.id, {"file_size_bytes": file.size}) + if "stateFile" in data: + stateFile: UploadFile = data["stateFile"] # type: ignore + fs_asset_handler.write_file(file=stateFile, path=db_state.file_path) + db_state = db_state_handler.update_state( + db_state.id, {"file_size_bytes": stateFile.size} + ) # Set the last played time for the current user - current_user = request.user - rom_user = db_rom_handler.get_rom_user(db_state.rom_id, current_user.id) + rom_user = db_rom_handler.get_rom_user(db_state.rom_id, request.user.id) if not rom_user: - rom_user = db_rom_handler.add_rom_user(db_state.rom_id, current_user.id) + rom_user = db_rom_handler.add_rom_user(db_state.rom_id, request.user.id) db_rom_handler.update_rom_user( rom_user.id, {"last_played": datetime.now(timezone.utc)} ) - db_state = db_state_handler.get_state(id) + # Refetch the state to get updated fields return StateSchema.model_validate(db_state) @protected_route(router.post, "/delete", [Scope.ASSETS_WRITE]) -async def delete_states(request: Request) -> MessageResponse: +async def delete_states(request: Request) -> list[int]: data: dict = await request.json() state_ids: list = data["states"] - delete_from_fs: list = data["delete_from_fs"] if not state_ids: error = "No states were provided" @@ -141,43 +178,33 @@ async def delete_states(request: Request) -> MessageResponse: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) for state_id in state_ids: - state = db_state_handler.get_state(state_id) + state = db_state_handler.get_state(user_id=request.user.id, id=state_id) if not state: - error = f"Save with ID {state_id} not found" + error = f"State with ID {state_id} not found" log.error(error) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) - if state.user_id != request.user.id: - error = "You are not authorized to delete this save state" - log.error(error) - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error) - db_state_handler.delete_state(state_id) + log.info(f"Deleting {state.file_name} from filesystem") - if state_id in delete_from_fs: - log.info(f"Deleting {state.file_name} from filesystem") - try: - fs_asset_handler.remove_file( - file_name=state.file_name, file_path=state.file_path - ) - except FileNotFoundError as exc: - error = f"Save file {state.file_name} not found for platform {state.rom.platform_slug}" - log.error(error) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=error - ) from exc + try: + fs_asset_handler.remove_file( + file_name=state.file_name, file_path=state.file_path + ) + except FileNotFoundError: + error = f"State file {state.file_name} not found for platform {state.rom.platform_slug}" + log.error(error) if state.screenshot: db_screenshot_handler.delete_screenshot(state.screenshot.id) - if delete_from_fs: - try: - fs_asset_handler.remove_file( - file_name=state.screenshot.file_name, - file_path=state.screenshot.file_path, - ) - except FileNotFoundError: - error = f"Screenshot file {state.screenshot.file_name} not found for state {state.file_name}" - log.error(error) + try: + fs_asset_handler.remove_file( + file_name=state.screenshot.file_name, + file_path=state.screenshot.file_path, + ) + except FileNotFoundError: + error = f"Screenshot file {state.screenshot.file_name} not found for state {state.file_name}" + log.error(error) - return {"msg": f"Successfully deleted {len(state_ids)} states"} + return state_ids diff --git a/backend/endpoints/tests/test_assets.py b/backend/endpoints/tests/test_assets.py index ef457fa64..48df14b36 100644 --- a/backend/endpoints/tests/test_assets.py +++ b/backend/endpoints/tests/test_assets.py @@ -13,21 +13,21 @@ def test_delete_saves(client, access_token, save): response = client.post( "/api/saves/delete", headers={"Authorization": f"Bearer {access_token}"}, - json={"saves": [save.id], "delete_from_fs": []}, + json={"saves": [save.id]}, ) assert response.status_code == 200 body = response.json() - assert body["msg"] == "Successfully deleted 1 saves" + assert len(body) == 1 def test_delete_states(client, access_token, state): response = client.post( "/api/states/delete", headers={"Authorization": f"Bearer {access_token}"}, - json={"states": [state.id], "delete_from_fs": []}, + json={"states": [state.id]}, ) assert response.status_code == 200 body = response.json() - assert body["msg"] == "Successfully deleted 1 states" + assert len(body) == 1 diff --git a/backend/handler/database/saves_handler.py b/backend/handler/database/saves_handler.py index f344a91ba..50624d56d 100644 --- a/backend/handler/database/saves_handler.py +++ b/backend/handler/database/saves_handler.py @@ -14,12 +14,12 @@ class DBSavesHandler(DBBaseHandler): return session.merge(save) @begin_session - def get_save(self, id: int, session: Session = None) -> Save | None: + def get_save(self, user_id: int, id: int, session: Session = None) -> Save | None: return session.get(Save, id) @begin_session def get_save_by_filename( - self, rom_id: int, user_id: int, file_name: str, session: Session = None + self, user_id: int, rom_id: int, file_name: str, session: Session = None ) -> Save | None: return session.scalars( select(Save) @@ -27,6 +27,24 @@ class DBSavesHandler(DBBaseHandler): .limit(1) ).first() + @begin_session + def get_saves( + self, + user_id: int, + rom_id: int | None = None, + platform_id: int | None = None, + session: Session = None, + ) -> Sequence[Save]: + query = select(Save).filter_by(user_id=user_id) + + if rom_id: + query = query.filter_by(rom_id=rom_id) + + if platform_id: + query = query.filter_by(platform_id=platform_id) + + return session.scalars(query).all() + @begin_session def update_save(self, id: int, data: dict, session: Session = None) -> Save: session.execute( diff --git a/backend/handler/database/states_handler.py b/backend/handler/database/states_handler.py index 1ce83629c..8d3760a0d 100644 --- a/backend/handler/database/states_handler.py +++ b/backend/handler/database/states_handler.py @@ -14,18 +14,36 @@ class DBStatesHandler(DBBaseHandler): return session.merge(state) @begin_session - def get_state(self, id: int, session: Session = None) -> State | None: - return session.get(State, id) + def get_state(self, user_id: int, id: int, session: Session = None) -> State | None: + return session.scalar(select(State).filter_by(user_id=user_id, id=id).limit(1)) @begin_session def get_state_by_filename( - self, rom_id: int, user_id: int, file_name: str, session: Session = None + self, user_id: int, rom_id: int, file_name: str, session: Session = None ) -> State | None: - return session.scalars( + return session.scalar( select(State) .filter_by(rom_id=rom_id, user_id=user_id, file_name=file_name) .limit(1) - ).first() + ) + + @begin_session + def get_states( + self, + user_id: int, + rom_id: int | None = None, + platform_id: int | None = None, + session: Session = None, + ) -> Sequence[State]: + query = select(State).filter_by(user_id=user_id) + + if rom_id: + query = query.filter_by(rom_id=rom_id) + + if platform_id: + query = query.filter_by(platform_id=platform_id) + + return session.scalars(query).all() @begin_session def update_state(self, id: int, data: dict, session: Session = None) -> State: diff --git a/backend/handler/tests/test_db_handler.py b/backend/handler/tests/test_db_handler.py index 651c8783e..50f5f36ee 100644 --- a/backend/handler/tests/test_db_handler.py +++ b/backend/handler/tests/test_db_handler.py @@ -137,12 +137,12 @@ def test_saves(save: Save, platform: Platform, admin_user: User): assert rom is not None assert len(rom.saves) == 2 - new_save = db_save_handler.get_save(rom.saves[0].id) + new_save = db_save_handler.get_save(user_id=admin_user.id, id=rom.saves[0].id) assert new_save is not None assert new_save.file_name == "test_save.sav" db_save_handler.update_save(new_save.id, {"file_name": "test_save_2.sav"}) - new_save = db_save_handler.get_save(new_save.id) + new_save = db_save_handler.get_save(user_id=admin_user.id, id=new_save.id) assert new_save is not None assert new_save.file_name == "test_save_2.sav" @@ -167,22 +167,22 @@ def test_states(state: State, platform: Platform, admin_user: User): ) ) - rom = db_rom_handler.get_rom(state.rom_id) + rom = db_rom_handler.get_rom(id=state.rom_id) assert rom is not None assert len(rom.states) == 2 - new_state = db_state_handler.get_state(rom.states[0].id) + new_state = db_state_handler.get_state(user_id=admin_user.id, id=rom.states[0].id) assert new_state is not None assert new_state.file_name == "test_state.state" db_state_handler.update_state(new_state.id, {"file_name": "test_state_2.state"}) - new_state = db_state_handler.get_state(new_state.id) + new_state = db_state_handler.get_state(user_id=admin_user.id, id=new_state.id) assert new_state is not None assert new_state.file_name == "test_state_2.state" - db_state_handler.delete_state(new_state.id) + db_state_handler.delete_state(id=new_state.id) - rom = db_rom_handler.get_rom(state.rom_id) + rom = db_rom_handler.get_rom(id=state.rom_id) assert rom is not None assert len(rom.states) == 1 @@ -205,19 +205,19 @@ def test_screenshots(screenshot: Screenshot, platform: Platform, admin_user: Use assert rom is not None assert len(rom.screenshots) == 2 - new_screenshot = db_screenshot_handler.get_screenshot(rom.screenshots[0].id) + new_screenshot = db_screenshot_handler.get_screenshot(id=rom.screenshots[0].id) assert new_screenshot is not None assert new_screenshot.file_name == "test_screenshot.png" db_screenshot_handler.update_screenshot( new_screenshot.id, {"file_name": "test_screenshot_2.png"} ) - new_screenshot = db_screenshot_handler.get_screenshot(new_screenshot.id) + new_screenshot = db_screenshot_handler.get_screenshot(id=new_screenshot.id) assert new_screenshot is not None assert new_screenshot.file_name == "test_screenshot_2.png" - db_screenshot_handler.delete_screenshot(new_screenshot.id) + db_screenshot_handler.delete_screenshot(id=new_screenshot.id) - rom = db_rom_handler.get_rom(screenshot.rom_id) + rom = db_rom_handler.get_rom(id=screenshot.rom_id) assert rom is not None assert len(rom.screenshots) == 1 diff --git a/backend/models/assets.py b/backend/models/assets.py index 51dce8cc1..1af398565 100644 --- a/backend/models/assets.py +++ b/backend/models/assets.py @@ -57,7 +57,7 @@ class Save(RomAsset): return None for screenshot in db_rom.screenshots: - if screenshot.file_name_no_ext == self.file_name: + if screenshot.file_name_no_ext == self.file_name_no_ext: return screenshot return None @@ -81,7 +81,7 @@ class State(RomAsset): return None for screenshot in db_rom.screenshots: - if screenshot.file_name_no_ext == self.file_name: + if screenshot.file_name_no_ext == self.file_name_no_ext: return screenshot return None diff --git a/backend/models/platform.py b/backend/models/platform.py index 7e4de74df..e3b02cdb1 100644 --- a/backend/models/platform.py +++ b/backend/models/platform.py @@ -53,7 +53,7 @@ class Platform(BaseModel): return self.name @cached_property - def filesystem_size_bytes(self) -> int: + def fs_size_bytes(self) -> int: from handler.database import db_stats_handler return db_stats_handler.get_platform_filesize(self.id) diff --git a/backend/models/rom.py b/backend/models/rom.py index e07338ef9..ad5eb0f90 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -200,12 +200,10 @@ class Rom(BaseModel): @cached_property def merged_screenshots(self) -> list[str]: - screenshots = [s.download_path for s in self.screenshots] if self.path_screenshots: - screenshots += [ - f"{FRONTEND_RESOURCES_PATH}/{s}" for s in self.path_screenshots - ] - return screenshots + return [f"{FRONTEND_RESOURCES_PATH}/{s}" for s in self.path_screenshots] + + return [] @cached_property def multi(self) -> bool: diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index cafc8c54c..093d09e03 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -6,9 +6,6 @@ export type { AddFirmwareResponse } from './models/AddFirmwareResponse'; export type { Body_add_collection_api_collections_post } from './models/Body_add_collection_api_collections_post'; export type { Body_add_firmware_api_firmware_post } from './models/Body_add_firmware_api_firmware_post'; -export type { Body_add_saves_api_saves_post } from './models/Body_add_saves_api_saves_post'; -export type { Body_add_screenshots_api_screenshots_post } from './models/Body_add_screenshots_api_screenshots_post'; -export type { Body_add_states_api_states_post } from './models/Body_add_states_api_states_post'; export type { Body_token_api_token_post } from './models/Body_token_api_token_post'; export type { Body_update_collection_api_collections__id__put } from './models/Body_update_collection_api_collections__id__put'; export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom_api_roms__id__put'; @@ -53,9 +50,6 @@ export type { TinfoilFeedFileSchema } from './models/TinfoilFeedFileSchema'; export type { TinfoilFeedSchema } from './models/TinfoilFeedSchema'; export type { TinfoilFeedTitleDBSchema } from './models/TinfoilFeedTitleDBSchema'; export type { TokenResponse } from './models/TokenResponse'; -export type { UploadedSavesResponse } from './models/UploadedSavesResponse'; -export type { UploadedScreenshotsResponse } from './models/UploadedScreenshotsResponse'; -export type { UploadedStatesResponse } from './models/UploadedStatesResponse'; export type { UserNotesSchema } from './models/UserNotesSchema'; export type { UserSchema } from './models/UserSchema'; export type { ValidationError } from './models/ValidationError'; diff --git a/frontend/src/__generated__/models/Body_add_saves_api_saves_post.ts b/frontend/src/__generated__/models/Body_add_saves_api_saves_post.ts deleted file mode 100644 index 8a2d0b0e2..000000000 --- a/frontend/src/__generated__/models/Body_add_saves_api_saves_post.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Body_add_saves_api_saves_post = { - saves: Array; -}; - diff --git a/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots_post.ts b/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots_post.ts deleted file mode 100644 index 73ef65873..000000000 --- a/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots_post.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Body_add_screenshots_api_screenshots_post = { - screenshots: Array; -}; - diff --git a/frontend/src/__generated__/models/Body_add_states_api_states_post.ts b/frontend/src/__generated__/models/Body_add_states_api_states_post.ts deleted file mode 100644 index 35e27869d..000000000 --- a/frontend/src/__generated__/models/Body_add_states_api_states_post.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Body_add_states_api_states_post = { - states: Array; -}; - diff --git a/frontend/src/__generated__/models/PlatformSchema.ts b/frontend/src/__generated__/models/PlatformSchema.ts index 4b6e820d1..8a4d180bb 100644 --- a/frontend/src/__generated__/models/PlatformSchema.ts +++ b/frontend/src/__generated__/models/PlatformSchema.ts @@ -25,7 +25,7 @@ export type PlatformSchema = { aspect_ratio?: string; created_at: string; updated_at: string; - filesystem_size_bytes: number; + fs_size_bytes: number; readonly display_name: string; }; diff --git a/frontend/src/__generated__/models/UploadedSavesResponse.ts b/frontend/src/__generated__/models/UploadedSavesResponse.ts deleted file mode 100644 index 03bc074c1..000000000 --- a/frontend/src/__generated__/models/UploadedSavesResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { SaveSchema } from './SaveSchema'; -export type UploadedSavesResponse = { - uploaded: number; - saves: Array; -}; - diff --git a/frontend/src/__generated__/models/UploadedScreenshotsResponse.ts b/frontend/src/__generated__/models/UploadedScreenshotsResponse.ts deleted file mode 100644 index 343645412..000000000 --- a/frontend/src/__generated__/models/UploadedScreenshotsResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ScreenshotSchema } from './ScreenshotSchema'; -export type UploadedScreenshotsResponse = { - uploaded: number; - screenshots: Array; - merged_screenshots: Array; -}; - diff --git a/frontend/src/__generated__/models/UploadedStatesResponse.ts b/frontend/src/__generated__/models/UploadedStatesResponse.ts deleted file mode 100644 index 6b2d19f56..000000000 --- a/frontend/src/__generated__/models/UploadedStatesResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { StateSchema } from './StateSchema'; -export type UploadedStatesResponse = { - uploaded: number; - states: Array; -}; - diff --git a/frontend/src/components/Details/Saves.vue b/frontend/src/components/Details/Saves.vue index 0e2cb775d..a0fc74c8a 100644 --- a/frontend/src/components/Details/Saves.vue +++ b/frontend/src/components/Details/Saves.vue @@ -1,52 +1,23 @@ - diff --git a/frontend/src/components/Details/States.vue b/frontend/src/components/Details/States.vue index 45d48ae81..434efad62 100644 --- a/frontend/src/components/Details/States.vue +++ b/frontend/src/components/Details/States.vue @@ -1,52 +1,23 @@ - diff --git a/frontend/src/components/Gallery/AppBar/Platform/PlatformInfoDrawer.vue b/frontend/src/components/Gallery/AppBar/Platform/PlatformInfoDrawer.vue index 22be49b0f..80aa59495 100644 --- a/frontend/src/components/Gallery/AppBar/Platform/PlatformInfoDrawer.vue +++ b/frontend/src/components/Gallery/AppBar/Platform/PlatformInfoDrawer.vue @@ -69,7 +69,7 @@ const PLATFORM_INFO_FIELDS: { { key: "generation", label: t("platform.generation"), format: identity }, { key: "family_name", label: t("platform.family"), format: identity }, { - key: "filesystem_size_bytes", + key: "fs_size_bytes", label: t("common.size-on-disk"), format: (fs: number) => formatBytes(fs, 2), }, diff --git a/frontend/src/components/common/Game/Dialog/Asset/DeleteAssets.vue b/frontend/src/components/common/Game/Dialog/Asset/DeleteAssets.vue deleted file mode 100644 index cee0ef032..000000000 --- a/frontend/src/components/common/Game/Dialog/Asset/DeleteAssets.vue +++ /dev/null @@ -1,218 +0,0 @@ - - - diff --git a/frontend/src/components/common/Game/Dialog/Asset/DeleteSaves.vue b/frontend/src/components/common/Game/Dialog/Asset/DeleteSaves.vue new file mode 100644 index 000000000..5fb41093e --- /dev/null +++ b/frontend/src/components/common/Game/Dialog/Asset/DeleteSaves.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/components/common/Game/Dialog/Asset/DeleteStates.vue b/frontend/src/components/common/Game/Dialog/Asset/DeleteStates.vue new file mode 100644 index 000000000..88cb8fd16 --- /dev/null +++ b/frontend/src/components/common/Game/Dialog/Asset/DeleteStates.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/components/common/Game/Dialog/Asset/SelectSave.vue b/frontend/src/components/common/Game/Dialog/Asset/SelectSave.vue new file mode 100644 index 000000000..0d82ecd50 --- /dev/null +++ b/frontend/src/components/common/Game/Dialog/Asset/SelectSave.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/components/common/Game/Dialog/Asset/SelectState.vue b/frontend/src/components/common/Game/Dialog/Asset/SelectState.vue new file mode 100644 index 000000000..ae0a4ba8c --- /dev/null +++ b/frontend/src/components/common/Game/Dialog/Asset/SelectState.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/components/common/Game/Dialog/Asset/UploadSaves.vue b/frontend/src/components/common/Game/Dialog/Asset/UploadSaves.vue index d2c70844f..c352f58d4 100644 --- a/frontend/src/components/common/Game/Dialog/Asset/UploadSaves.vue +++ b/frontend/src/components/common/Game/Dialog/Asset/UploadSaves.vue @@ -54,13 +54,15 @@ async function uploadSaves() { saveApi .uploadSaves({ rom: rom.value, - saves: filesToUpload.value, + savesToUpload: filesToUpload.value.map((saveFile) => ({ + saveFile, + })), }) - .then(({ data }) => { - const { saves, uploaded } = data; + .then((data) => { + const saves = data; emitter?.emit("snackbarShow", { - msg: `Uploaded ${uploaded} files successfully!`, + msg: `Uploaded ${saves.length} files successfully!`, icon: "mdi-check-bold", color: "green", timeout: 2000, diff --git a/frontend/src/components/common/Game/Dialog/Asset/UploadStates.vue b/frontend/src/components/common/Game/Dialog/Asset/UploadStates.vue index f20ac078f..d330c5fb5 100644 --- a/frontend/src/components/common/Game/Dialog/Asset/UploadStates.vue +++ b/frontend/src/components/common/Game/Dialog/Asset/UploadStates.vue @@ -54,17 +54,29 @@ function uploadStates() { stateApi .uploadStates({ rom: rom.value, - states: filesToUpload.value, + statesToUpload: filesToUpload.value.map((stateFile) => ({ + stateFile, + })), }) - .then(({ data }) => { - const { states, uploaded } = data; + .then((data) => { + const saves = data; emitter?.emit("snackbarShow", { - msg: `${uploaded} files uploaded successfully.`, + msg: `Uploaded ${saves.length} files successfully!`, icon: "mdi-check-bold", color: "green", timeout: 2000, }); + }) + .catch(({ response, message }) => { + emitter?.emit("snackbarShow", { + msg: `Unable to upload saves: ${ + response?.data?.detail || response?.statusText || message + }`, + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); }); closeDialog(); diff --git a/frontend/src/components/common/Notifications/Notification.vue b/frontend/src/components/common/Notifications/Notification.vue index 3d732ebf0..15078fa55 100644 --- a/frontend/src/components/common/Notifications/Notification.vue +++ b/frontend/src/components/common/Notifications/Notification.vue @@ -33,14 +33,16 @@ function closeDialog() { @timeout="closeDialog" absolute :location="xs ? 'top' : 'top right'" - color="tooltip" + color="primary-darken" > - - {{ snackbarStatus.msg }} + diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index d1f15170a..90dc5c8af 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -1,11 +1,14 @@ { "full-screen": "Vollbild", "play": "Spielen", - "reset-session": "Session zurücksetzen", + "quit": "Beenden", + "save-and-quit": "Speichern und beenden", "back-to-game-details": "Zurück zu den Spieldetails", "back-to-gallery": "Zurück zur Plattformübersicht", "clear-cache": "EmulatorJS-Cache löschen", "clear-cache-title": "Möchtest du den EmulatorJS-Cache wirklich löschen?", "clear-cache-warning": "Dadurch werden alle im Browser gespeicherten Spielstände und Speicherungen entfernt.", - "clear-cache-description": "Es hat keine Auswirkungen auf Spielstände und Speicherungen, die auf dem Server gespeichert sind." + "clear-cache-description": "Es hat keine Auswirkungen auf Spielstände und Speicherungen, die auf dem Server gespeichert sind.", + "select-save": "Speicherstand auswählen", + "select-state": "Speicherstand auswählen" } diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index ecf6bc4b9..4674ef1d0 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -1,11 +1,14 @@ { - "full-screen": "Full Screen", + "full-screen": "Full screen", "play": "Play", - "reset-session": "Reset Session", + "quit": "Quit", + "save-and-quit": "Save and quit", "back-to-game-details": "Back to game details", "back-to-gallery": "Back to gallery", "clear-cache": "Clear EmulatorJS Cache", "clear-cache-title": "Are you sure you want to clear the EmulatorJS cache?", "clear-cache-warning": "This will remove all saves and states stored in the browser.", - "clear-cache-description": "Any saves or states stored on the server will not be affected." + "clear-cache-description": "Any saves or states stored on the server will not be affected.", + "select-save": "Select save", + "select-state": "Select state" } diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index ecf6bc4b9..4674ef1d0 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -1,11 +1,14 @@ { - "full-screen": "Full Screen", + "full-screen": "Full screen", "play": "Play", - "reset-session": "Reset Session", + "quit": "Quit", + "save-and-quit": "Save and quit", "back-to-game-details": "Back to game details", "back-to-gallery": "Back to gallery", "clear-cache": "Clear EmulatorJS Cache", "clear-cache-title": "Are you sure you want to clear the EmulatorJS cache?", "clear-cache-warning": "This will remove all saves and states stored in the browser.", - "clear-cache-description": "Any saves or states stored on the server will not be affected." + "clear-cache-description": "Any saves or states stored on the server will not be affected.", + "select-save": "Select save", + "select-state": "Select state" } diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index 7baf7b4db..b1e23c527 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -1,11 +1,14 @@ { "full-screen": "Pantalla completa", "play": "Jugar", - "reset-session": "Restablecer sesión", + "quit": "Salir", + "save-and-quit": "Guardar y salir", "back-to-game-details": "Volver a detalles", "back-to-gallery": "Volver a galería", "clear-cache": "Limpiar caché de EmulatorJS", "clear-cache-title": "¿Estás seguro de que quieres limpiar la caché de EmulatorJS?", "clear-cache-warning": "Esto eliminará todas las partidas y estados almacenados en el navegador.", - "clear-cache-description": "No afectará a las partidas o estados almacenados en el servidor." + "clear-cache-description": "No afectará a las partidas guardadas o estados almacenados en el servidor.", + "select-save": "Seleccionar guardado", + "select-state": "Seleccionar estado" } diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index 312ed7774..263e48251 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -1,11 +1,14 @@ { "full-screen": "Plein écran", "play": "Jouer", - "reset-session": "Réinitialiser la session", + "quit": "Quitter", + "save-and-quit": "Sauvegarder et quitter", "back-to-game-details": "Retour aux détails du jeu", "back-to-gallery": "Retour à la galerie", "clear-cache": "Effacer le cache EmulatorJS", "clear-cache-title": "Êtes-vous sûr de vouloir effacer le cache EmulatorJS ?", "clear-cache-warning": "Cela supprimera toutes les sauvegardes et les états stockés dans le navigateur.", - "clear-cache-description": "Les sauvegardes ou les états stockés sur le serveur ne seront pas affectés." + "clear-cache-description": "Les sauvegardes ou les états stockés sur le serveur ne seront pas affectés.", + "select-save": "Sélectionner la sauvegarde", + "select-state": "Sélectionner l'état" } diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index 25a80c37f..712f18820 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -1,11 +1,14 @@ { "full-screen": "全画面", "play": "プレイ", - "reset-session": "セッションをリセット", + "quit": "終了", + "save-and-quit": "保存して終了", "back-to-game-details": "ゲーム詳細へ戻る", "back-to-gallery": "ギャラリーへ戻る", "clear-cache": "EmulatorJS キャッシュをクリア", "clear-cache-title": "EmulatorJS キャッシュをクリアしてもよろしいですか?", "clear-cache-warning": "これにより、ブラウザに保存されているすべてのセーブデータとステートが削除されます。", - "clear-cache-description": "サーバーに保存されているセーブデータやステートには影響しません。" + "clear-cache-description": "サーバーに保存されているセーブデータやステートには影響しません。", + "select-save": "セーブデータを選択", + "select-state": "ステートを選択" } diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index cb75e5532..2d85c95c3 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -1,11 +1,14 @@ { "full-screen": "전체 화면", "play": "실행", - "reset-session": "세션 초기화", + "quit": "종료", + "save-and-quit": "저장하고 종료", "back-to-game-details": "게임 설명으로 가기", "back-to-gallery": "갤러리로 가기", "clear-cache": "EmulatorJS 캐시 지우기", "clear-cache-title": "EmulatorJS 캐시를 지우시겠습니까?", "clear-cache-warning": "이로 인해 브라우저에 저장된 모든 세이브 및 상태가 제거됩니다.", - "clear-cache-description": "서버에 저장된 세이브 및 상태에는 영향을 주지 않습니다." + "clear-cache-description": "서버에 저장된 세이브 및 상태에는 영향을 주지 않습니다.", + "select-save": "세이브 선택", + "select-state": "상태 선택" } diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index 2605bad1b..a8b717cbe 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -1,11 +1,14 @@ { "full-screen": "Tela cheia", "play": "Jogar", - "reset-session": "Redefinir sessão", + "quit": "Sair", + "save-and-quit": "Salvar e sair", "back-to-game-details": "Voltar aos detalhes do jogo", "back-to-gallery": "Voltar à galeria", "clear-cache": "Limpar cache do EmulatorJS", "clear-cache-title": "Tem certeza de que deseja limpar o cache do EmulatorJS?", "clear-cache-warning": "Isso removerá todos os saves e estados armazenados no navegador.", - "clear-cache-description": "Não afetará nenhum save ou estado armazenado no servidor." + "clear-cache-description": "Não afetará nenhum save ou estado armazenado no servidor.", + "select-save": "Selecionar save", + "select-state": "Selecionar estado" } diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index 8bf1cd03e..47d2140c9 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -1,11 +1,14 @@ { "full-screen": "Ecran complet", "play": "Joacă", - "reset-session": "Resetează sesiunea", + "quit": "Ieși", + "save-and-quit": "Salvează și ieși", "back-to-game-details": "Înapoi la detaliile jocului", "back-to-gallery": "Înapoi la galerie", "clear-cache": "Șterge cache-ul EmulatorJS", "clear-cache-title": "Ești sigur că vrei să ștergi cache-ul EmulatorJS?", "clear-cache-warning": "Acest lucru va elimina toate salvările și stările stocate în browser.", - "clear-cache-description": "Nu va afecta nicio salvare sau stare stocată pe server." + "clear-cache-description": "Nu va afecta nicio salvare sau stare stocată pe server.", + "select-save": "Selectează salvare", + "select-state": "Selectează stare" } diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index e8fb45302..2285cd4ce 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -1,11 +1,14 @@ { "full-screen": "Полный экран", "play": "Играть", - "reset-session": "Сбросить сессию", + "quit": "Выйти", + "save-and-quit": "Сохранить и выйти", "back-to-game-details": "Вернуться к деталям игры", "back-to-gallery": "Вернуться в галерею", "clear-cache": "Очистить кэш EmulatorJS", "clear-cache-title": "Вы уверены, что хотите очистить кэш EmulatorJS?", "clear-cache-warning": "Это удалит все сохранения и состояния, хранящиеся в браузере.", - "clear-cache-description": "Любые сохранения или состояния, хранящиеся на сервере, не будут затронуты." + "clear-cache-description": "Любые сохранения или состояния, хранящиеся на сервере, не будут затронуты.", + "select-save": "Выбрать сохранение", + "select-state": "Выбрать состояние" } diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index 90658dcf9..9f1c33d26 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -1,7 +1,8 @@ { "full-screen": "全屏", "play": "游玩", - "reset-session": "重置会话", + "quit": "退出", + "save-and-quit": "保存并退出", "back-to-game-details": "返回游戏详情", "back-to-gallery": "返回游戏库", "clear-cache": "清除 EmulatorJS 缓存", diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index dfc66ceee..a4099e67b 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -2,6 +2,7 @@ import type { MessageResponse, SearchRomSchema, RomUserSchema, + RomSchema, } from "@/__generated__"; import api from "@/services/api/index"; import socket from "@/services/socket"; @@ -19,7 +20,7 @@ async function uploadRoms({ }: { platformId: number; filesToUpload: File[]; -}): Promise[]> { +}): Promise[]> { const heartbeat = storeHeartbeat(); if (!socket.connected) socket.connect(); @@ -30,7 +31,7 @@ async function uploadRoms({ formData.append(file.name, file); uploadStore.start(file.name); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { api .post("/roms", formData, { headers: { @@ -44,7 +45,9 @@ async function uploadRoms({ uploadStore.update(file.name, progressEvent); }, }) - .then(resolve) + .then(({ data }) => { + resolve(data); + }) .catch((error) => { uploadStore.fail(file.name, error.response?.data?.detail); reject(error); diff --git a/frontend/src/services/api/save.ts b/frontend/src/services/api/save.ts index 63839553f..0222a7218 100644 --- a/frontend/src/services/api/save.ts +++ b/frontend/src/services/api/save.ts @@ -1,57 +1,71 @@ -import type { SaveSchema, UploadedSavesResponse } from "@/__generated__"; import api from "@/services/api/index"; import type { DetailedRom } from "@/stores/roms"; +import type { SaveSchema } from "@/__generated__"; export const saveApi = api; async function uploadSaves({ rom, - saves, + savesToUpload, emulator, }: { rom: DetailedRom; - saves: File[]; + savesToUpload: { + saveFile: File; + screenshotFile?: File; + }[]; emulator?: string; -}): Promise<{ data: UploadedSavesResponse }> { - const formData = new FormData(); - saves.forEach((save) => formData.append("saves", save)); +}): Promise[]> { + const promises = savesToUpload.map(({ saveFile, screenshotFile }) => { + const formData = new FormData(); + formData.append("saveFile", saveFile); + if (screenshotFile) { + formData.append("screenshotFile", screenshotFile); + } - return api.post("/saves", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - params: { rom_id: rom.id, emulator }, + return new Promise((resolve, reject) => { + api + .post("/saves", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + params: { rom_id: rom.id, emulator }, + }) + .then(({ data }) => { + resolve(data); + }) + .catch(reject); + }); }); + + return Promise.allSettled(promises); } async function updateSave({ save, - file, + saveFile, }: { save: SaveSchema; - file: File; + saveFile: File; }): Promise<{ data: SaveSchema }> { const formData = new FormData(); - formData.append("file", file); + formData.append("saveFile", saveFile); return api.put(`/saves/${save.id}`, formData); } async function deleteSaves({ saves, - deleteFromFs, }: { saves: SaveSchema[]; - deleteFromFs: number[]; -}) { +}): Promise<{ data: number[] }> { return api.post("/saves/delete", { saves: saves.map((s) => s.id), - delete_from_fs: deleteFromFs, }); } export default { - deleteSaves, - updateSave, uploadSaves, + updateSave, + deleteSaves, }; diff --git a/frontend/src/services/api/screenshot.ts b/frontend/src/services/api/screenshot.ts index f51ae00c7..43f752b66 100644 --- a/frontend/src/services/api/screenshot.ts +++ b/frontend/src/services/api/screenshot.ts @@ -1,41 +1,51 @@ -import type { - ScreenshotSchema, - UploadedScreenshotsResponse, -} from "@/__generated__"; import api from "@/services/api/index"; import type { DetailedRom } from "@/stores/roms"; +import type { ScreenshotSchema } from "@/__generated__"; export const screenshotApi = api; async function uploadScreenshots({ rom, - screenshots, + screenshotsToUpload, + emulator, }: { rom: DetailedRom; - screenshots: File[]; -}): Promise<{ data: UploadedScreenshotsResponse }> { - const formData = new FormData(); - screenshots.forEach((screenshot) => - formData.append("screenshots", screenshot), - ); + screenshotsToUpload: { + screenshotFile: File; + }[]; + emulator?: string; +}): Promise[]> { + const promises = screenshotsToUpload.map(({ screenshotFile }) => { + const formData = new FormData(); + formData.append("screenshotFile", screenshotFile); - return api.post("/screenshots", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - params: { rom_id: rom.id }, + return new Promise((resolve, reject) => { + api + .post("/screenshots", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + params: { rom_id: rom.id, emulator }, + }) + .then(({ data }) => { + resolve(data); + }) + .catch(reject); + }); }); + + return Promise.allSettled(promises); } async function updateScreenshot({ screenshot, - file, + screenshotFile, }: { screenshot: ScreenshotSchema; - file: File; + screenshotFile: File; }): Promise<{ data: ScreenshotSchema }> { const formData = new FormData(); - formData.append("file", file); + formData.append("screenshotFile", screenshotFile); return api.put(`/screenshots/${screenshot.id}`, formData); } diff --git a/frontend/src/services/api/state.ts b/frontend/src/services/api/state.ts index 3da9a73ea..23881bedf 100644 --- a/frontend/src/services/api/state.ts +++ b/frontend/src/services/api/state.ts @@ -1,52 +1,66 @@ -import type { StateSchema, UploadedStatesResponse } from "@/__generated__"; import api from "@/services/api/index"; import type { DetailedRom } from "@/stores/roms"; +import type { StateSchema } from "@/__generated__"; export const stateApi = api; async function uploadStates({ rom, - states, + statesToUpload, emulator, }: { rom: DetailedRom; - states: File[]; + statesToUpload: { + stateFile: File; + screenshotFile?: File; + }[]; emulator?: string; -}): Promise<{ data: UploadedStatesResponse }> { - const formData = new FormData(); - states.forEach((state) => formData.append("states", state)); +}): Promise[]> { + const promises = statesToUpload.map(({ stateFile, screenshotFile }) => { + const formData = new FormData(); + formData.append("stateFile", stateFile); + if (screenshotFile) { + formData.append("screenshotFile", screenshotFile); + } - return api.post("/states", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - params: { rom_id: rom.id, emulator }, + return new Promise((resolve, reject) => { + api + .post("/states", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + params: { rom_id: rom.id, emulator }, + }) + .then(({ data }) => { + resolve(data); + }) + .catch(reject); + }); }); + + return Promise.allSettled(promises); } async function updateState({ state, - file, + stateFile, }: { state: StateSchema; - file: File; + stateFile: File; }): Promise<{ data: StateSchema }> { const formData = new FormData(); - formData.append("file", file); + formData.append("stateFile", stateFile); return api.put(`/states/${state.id}`, formData); } async function deleteStates({ states, - deleteFromFs, }: { states: StateSchema[]; - deleteFromFs: number[]; -}) { +}): Promise<{ data: number[] }> { return api.post("/states/delete", { states: states.map((s) => s.id), - delete_from_fs: deleteFromFs, }); } diff --git a/frontend/src/styles/themes.ts b/frontend/src/styles/themes.ts index e0d7f3c7a..82766313c 100644 --- a/frontend/src/styles/themes.ts +++ b/frontend/src/styles/themes.ts @@ -1,7 +1,8 @@ const commonColors = { - "romm-red": "#da3633", + "romm-red": "#DA3633", "romm-green": "#3FB950", - "romm-white": "#fefdfe", + "romm-blue": "#0070F3", + "romm-white": "#FEFDFE", "romm-gray": "#5D5D5D", "romm-black": "#000000", }; @@ -27,7 +28,7 @@ export const light = { dark: false, colors: { primary: "#371f69", - secondary: "#553e98", + secondary: "#553E98", accent: "#E1A38D", surface: "#FFFFFF", background: "#F2F4F8", diff --git a/frontend/src/types/emitter.d.ts b/frontend/src/types/emitter.d.ts index 1c10ff8b8..a1d603cd5 100644 --- a/frontend/src/types/emitter.d.ts +++ b/frontend/src/types/emitter.d.ts @@ -75,6 +75,9 @@ export type Events = { firmwareDrawerShow: null; updateDataTablePages: null; sortBarShow: null; - romUpdated: DetailedRom; showQRCodeDialog: SimpleRom; + selectSaveDialog: DetailedRom; + selectStateDialog: DetailedRom; + saveSelected: SaveSchema; + stateSelected: StateSchema; }; diff --git a/frontend/src/utils/covers.ts b/frontend/src/utils/covers.ts index 373e1318e..7631e88ae 100644 --- a/frontend/src/utils/covers.ts +++ b/frontend/src/utils/covers.ts @@ -32,7 +32,7 @@ export function getCollectionCoverImage(name: string): string { const tbgs = translatedBGs(name); const bgr = bgRotation(name); - const svgString = ``; + const svgString = ``; return strToObjUrl(svgString); } @@ -41,7 +41,7 @@ export function getFavoriteCoverImage(name: string): string { const tbgs = translatedBGs(name); const bgr = bgRotation(name); - const svgString = ``; + const svgString = ``; return strToObjUrl(svgString); } @@ -51,7 +51,7 @@ export function getMissingCoverImage(name: string): string { const bgr = bgRotation(name); const icoR = [90, 0, 270, 180][hashString(name) % 4]; - const svgString = ``; + const svgString = ``; return strToObjUrl(svgString); } @@ -60,7 +60,7 @@ export function getUnmatchedCoverImage(name: string): string { const tbgs = translatedBGs(name); const bgr = bgRotation(name); - const svgString = ``; + const svgString = ``; return strToObjUrl(svgString); } @@ -69,7 +69,7 @@ export function getEmptyCoverImage(name: string): string { const tbgs = translatedBGs(name); const bgr = bgRotation(name); - const svgString = ``; + const svgString = ``; return strToObjUrl(svgString); } diff --git a/frontend/src/utils/indexdb-monitor.ts b/frontend/src/utils/indexdb-monitor.ts index c1cc81b32..00385706b 100644 --- a/frontend/src/utils/indexdb-monitor.ts +++ b/frontend/src/utils/indexdb-monitor.ts @@ -23,7 +23,7 @@ type EventType = "change" | "error"; type EventsListener = (changes: Change[]) => void; type ErrorsListener = (error: Error) => void; -interface DiffMonitor { +export interface DiffMonitor { start: () => void; stop: () => void; getChanges: () => Change[]; diff --git a/frontend/src/views/GameDetails.vue b/frontend/src/views/GameDetails.vue index 0193a932d..21ecc0006 100644 --- a/frontend/src/views/GameDetails.vue +++ b/frontend/src/views/GameDetails.vue @@ -190,10 +190,6 @@ watch( > - - - + + @@ -224,7 +227,10 @@ watch( - - - - - - - - - diff --git a/frontend/src/views/Player/EmulatorJS/utils.ts b/frontend/src/views/Player/EmulatorJS/utils.ts new file mode 100644 index 000000000..ffb700e11 --- /dev/null +++ b/frontend/src/views/Player/EmulatorJS/utils.ts @@ -0,0 +1,180 @@ +import saveApi from "@/services/api/save"; +import stateApi from "@/services/api/state"; +import { type DetailedRom } from "@/stores/roms"; +import { type SaveSchema } from "@/__generated__"; +import { type StateSchema } from "@/__generated__"; + +function buildStateName(rom: DetailedRom): string { + const romName = rom.fs_name_no_ext.trim(); + return `${romName} [${new Date().toISOString().replace(/[:.]/g, "-").replace("T", " ").replace("Z", "")}]`; +} + +function buildSaveName(rom: DetailedRom): string { + const romName = rom.fs_name_no_ext.trim(); + return `${romName} [${new Date().toISOString().replace(/[:.]/g, "-").replace("T", " ").replace("Z", "")}]`; +} + +export async function saveState({ + rom, + stateFile, + screenshotFile, +}: { + rom: DetailedRom; + stateFile: Uint8Array; + screenshotFile?: Uint8Array; +}): Promise { + const filename = buildStateName(rom); + try { + const uploadedStates = await stateApi.uploadStates({ + rom: rom, + emulator: window.EJS_core, + statesToUpload: [ + { + stateFile: new File([stateFile], `${filename}.state`, { + type: "application/octet-stream", + }), + screenshotFile: screenshotFile + ? new File([screenshotFile], `${filename}.png`, { + type: "application/octet-stream", + }) + : undefined, + }, + ], + }); + + const uploadedState = uploadedStates[0]; + if (uploadedState.status == "fulfilled") { + if (rom) rom.user_states.unshift(uploadedState.value); + return uploadedState.value; + } + } catch (error) { + console.error("Failed to upload state", error); + } + + return null; +} + +export async function saveSave({ + rom, + save, + saveFile, + screenshotFile, +}: { + rom: DetailedRom; + save: SaveSchema | null; + saveFile: Uint8Array; + screenshotFile?: Uint8Array; +}): Promise { + if (save) { + try { + const { data: updateSave } = await saveApi.updateSave({ + save: save, + saveFile: new File([saveFile], save.file_name, { + type: "application/octet-stream", + }), + }); + return updateSave; + } catch (error) { + console.error("Failed to update save", error); + return null; + } + } + + const filename = buildSaveName(rom); + try { + const uploadedSaves = await saveApi.uploadSaves({ + rom: rom, + emulator: window.EJS_core, + savesToUpload: [ + { + saveFile: new File([saveFile], `${filename}.srm`, { + type: "application/octet-stream", + }), + screenshotFile: screenshotFile + ? new File([screenshotFile], `${filename}.png`, { + type: "application/octet-stream", + }) + : undefined, + }, + ], + }); + + const uploadedSave = uploadedSaves[0]; + if (uploadedSave.status == "fulfilled") { + if (rom) rom.user_saves.unshift(uploadedSave.value); + return uploadedSave.value; + } + } catch (error) { + console.error("Failed to upload save", error); + } + + return null; +} + +export function loadEmulatorJSSave(save: Uint8Array) { + const FS = window.EJS_emulator.gameManager.FS; + const path = window.EJS_emulator.gameManager.getSaveFilePath(); + const paths = path.split("/"); + let cp = ""; + for (let i = 0; i < paths.length - 1; i++) { + if (paths[i] === "") continue; + cp += "/" + paths[i]; + if (!FS.analyzePath(cp).exists) FS.mkdir(cp); + } + if (FS.analyzePath(path).exists) FS.unlink(path); + FS.writeFile(path, save); + window.EJS_emulator.gameManager.loadSaveFiles(); +} + +export function loadEmulatorJSState(state: Uint8Array) { + window.EJS_emulator.gameManager.loadState(state); +} + +export function createQuickLoadButton(): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("role", "presentation"); + svg.setAttribute("focusable", "false"); + svg.setAttribute("viewBox", "2 2 20 20"); + svg.innerHTML = + ''; + const text = document.createElement("span"); + text.classList.add("ejs_menu_text"); + text.innerText = "Load Latest State"; + button.classList.add("ejs_menu_button"); + button.appendChild(svg); + button.appendChild(text); + + const ejsMenuBar = document.querySelector("#game .ejs_menu_bar"); + const loadStateBtn = ejsMenuBar?.querySelector( + ".ejs_menu_button:nth-child(5)", + ); + if (ejsMenuBar && loadStateBtn) { + ejsMenuBar.insertBefore(button, loadStateBtn); + } + + return button; +} + +export function createSaveQuitButton(): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("role", "presentation"); + svg.setAttribute("focusable", "false"); + svg.setAttribute("viewBox", "2 2 20 20"); + svg.innerHTML = + ''; + const text = document.createElement("span"); + text.classList.add("ejs_menu_text", "ejs_menu_text_right"); + text.innerText = "Save & Quit"; + button.classList.add("ejs_menu_button"); + button.appendChild(svg); + button.appendChild(text); + + const ejsMenuBar = document.querySelector("#game .ejs_menu_bar"); + ejsMenuBar?.appendChild(button); + + return button; +} diff --git a/frontend/src/views/Player/RuffleRS/Base.vue b/frontend/src/views/Player/RuffleRS/Base.vue index afbf32d0f..e214689f1 100644 --- a/frontend/src/views/Player/RuffleRS/Base.vue +++ b/frontend/src/views/Player/RuffleRS/Base.vue @@ -60,6 +60,10 @@ function onFullScreenChange() { localStorage.setItem("fullScreenOnPlay", fullScreenOnPlay.value.toString()); } +async function onlyQuit() { + window.history.back(); +} + onMounted(async () => { const romResponse = await romApi.getRom({ romId: parseInt(route.params.rom as string), @@ -148,42 +152,46 @@ onMounted(async () => { + + {{ t("play.back-to-game-details") }} + + {{ t("play.back-to-gallery") }} + + {{ t("play.reset-session") }} - - {{ t("play.back-to-game-details") }} - - {{ t("play.back-to-gallery") }} + prepend-icon="mdi-exit-to-app" + @click="onlyQuit" + > + {{ t("play.quit") }} diff --git a/pytest.ini b/pytest.ini index 56e64e9b2..71d8b3f49 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,6 +5,7 @@ env = DB_NAME=romm_test DB_USER=romm_test DB_PASSWD=passwd + ROMM_DB_DRIVER=mariadb IGDB_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx IGDB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ROMM_AUTH_SECRET_KEY=843f6cefc5ba1430d54061301c2893be00c2aef11dae39ffec13a2af1a86e867