Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Green
9b8874902a Migrate to new EJS CDN, and save state tweaks (#264)
* Saved game icon now displays on game cover art in library

* Fixed casing error on save state download icon

* Migrate EJS from submodule to 7z download during docker build

* Updated README and gitignore

* Resized library search buttons

* Export to JSON now triggers the download rather than display of a formatted platform map
2024-01-20 16:12:21 +11:00
Michael Green
127eab683b Save states are now saved to the Gaseous host, making them available anywhere (#255)
* Added ability to save emulator state

* Save states can now be fully managed during a game

* Save states can also be launched from the game info screen
2024-01-15 11:37:18 +11:00
Michael Green
1efc47f9cd Re-enabled artwork backgrounds
* Re-enabled artwork backgrounds
2024-01-10 22:44:47 +11:00
37 changed files with 1074 additions and 160 deletions

View File

@@ -9,9 +9,3 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "gitsubmodule"
directory: "/"
allow:
- dependency-name: "gaseous-server/wwwroot/emulators/EmulatorJS"
schedule:
interval: "weekly"

1
.gitignore vendored
View File

@@ -404,3 +404,4 @@ ASALocalRun/
# Local History for Visual Studio
.localhistory/
gaseous-server/.DS_Store
gaseous-server/wwwroot/emulators/EmulatorJS

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "gaseous-server/wwwroot/emulators/EmulatorJS"]
path = gaseous-server/wwwroot/emulators/EmulatorJS
url = https://github.com/EmulatorJS/EmulatorJS.git

View File

@@ -9,6 +9,12 @@ RUN dotnet restore "gaseous-server/gaseous-server.csproj"
# Build and publish a release
RUN dotnet publish "gaseous-server/gaseous-server.csproj" --use-current-runtime --self-contained false -c Release -o out
# download and unzip EmulatorJS from CDN
RUN apt-get update && apt-get install -y p7zip-full
RUN mkdir -p out/wwwroot/emulators/EmulatorJS
RUN wget https://cdn.emulatorjs.org/releases/4.0.9.7z
RUN 7z x -y -oout/wwwroot/emulators/EmulatorJS 4.0.9.7z
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /App

View File

@@ -85,10 +85,9 @@ Dockerfile and docker-compose.yml files have been provided to make deployment of
Dockerfile and docker-compose-build.yml files have been provided to make deployment of the server as easy as possible.
1. Clone the repo with ```git clone https://github.com/gaseous-project/gaseous-server.git```
2. Change into the gaseous-server directory
3. Clone the submodules with the command ```git submodule update --init```
4. Open the docker-compose-{database}-build.yml file and edit the igdbclientid and igdbclientsecret to the values retrieved from your IGDB account
5. Run the command ```docker-compose --file docker-compose-{database}-build.yml up -d```
6. Connect to the host on port 5198
3. Open the docker-compose-{database}-build.yml file and edit the igdbclientid and igdbclientsecret to the values retrieved from your IGDB account
4. Run the command ```docker-compose --file docker-compose-{database}-build.yml up -d```
5. Connect to the host on port 5198
## Source
### Build and deploy
@@ -100,8 +99,7 @@ Dockerfile and docker-compose-build.yml files have been provided to make deploym
5. Change into the gaseous-server directory
6. As the main branch is the development branch, you might want to change to a stable version - these are tagged with a version number. For example to change to the 1.5.0 release, use the command ```git checkout v1.5.0```
* Check the releases page for the version you would like to run: https://github.com/gaseous-project/gaseous-server/releases
7. Clone the submodules with the command ```git submodule update --init --recursive```
* This command will clone the code that the server uses from other projects (currently only EmulatorJS)
7. Download the emulator files from ```https://cdn.emulatorjs.org/releases/4.0.9.zip``` and extract the files to ```gaseous-server/wwwroot/emulators/EmulatorJS```
8. Create a directory in the home directory of the user that will run the server. For example, if running as the user ```gaseous```, create the directory ```/home/gaseous/.gaseous-server```
9. Change into the ```.gaseous-server``` directory created in the previous step
10. Copy the JSON from the config file above into a new file named ```config.json```
@@ -115,11 +113,6 @@ Dockerfile and docker-compose-build.yml files have been provided to make deploym
**Note**: The above instructions were tested on macOS Ventura, and Ubuntu 22.04.3. There was a report that Debian 11 had an issue with the git submodule commands (see: https://github.com/gaseous-project/gaseous-server/issues/71). This was possibly due to an older git package.
If the git submodule commands aren't working, you can:
1. change to the ```gaseous-server/wwwroot/emulators``` directory
2. delete the ```EmulatorJS``` directory
3. clone the EmulatorJS repository with ```git clone https://github.com/EmulatorJS/EmulatorJS.git```
### Updating from source
1. Stop the server
2. Switch to the source directory
@@ -128,8 +121,7 @@ If the git submodule commands aren't working, you can:
* If running from another branch or tag, run:
* ```git fetch```
* ```git checkout <branch or tag name>```
4. Update the submodules with ```git submodule update --recursive```
5. Run steps 12 and 13 from the above Build guide
4. Run steps 12 and 13 from the above Build guide
# Adding Content
While games can be added to the server without them, it is recommended adding some signature DAT files beforehand to allow for better matching of ROMs to games.

Binary file not shown.

View File

@@ -289,7 +289,7 @@ namespace Authentication
/// <returns></returns>
private int Delete(string userId)
{
string commandText = "Delete from Users where Id = @userId; Delete from User_Settings where Id = @userId;";
string commandText = "Delete from Users where Id = @userId; Delete from User_Settings where Id = @userId; Delete from GameState where UserId = @userId;";
Dictionary<string, object> parameters = new Dictionary<string, object>();
parameters.Add("@userId", userId);

View File

@@ -529,6 +529,11 @@ namespace gaseous_server.Classes.Metadata
public class MinimalGameItem
{
public MinimalGameItem()
{
}
public MinimalGameItem(Game gameObject)
{
this.Id = gameObject.Id;
@@ -558,6 +563,7 @@ namespace gaseous_server.Classes.Metadata
public string Name { get; set; }
public double? TotalRating { get; set; }
public int? TotalRatingCount { get; set; }
public bool HasSavedGame { get; set; } = false;
public DateTimeOffset? FirstReleaseDate { get; set; }
public IGDB.IdentityOrValue<IGDB.Models.Cover> Cover { get; set; }
public IGDB.IdentitiesOrValues<IGDB.Models.Artwork> Artworks { get; set; }

View File

@@ -55,12 +55,15 @@ namespace gaseous_server.Classes
return GetMediaGroup(mgId);
}
public static GameRomMediaGroupItem GetMediaGroup(long Id)
public static GameRomMediaGroupItem GetMediaGroup(long Id, string userid = "")
{
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT * FROM RomMediaGroup WHERE Id=@id;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", Id);
string sql = "SELECT DISTINCT RomMediaGroup.*, GameState.RomId AS GameStateRomId FROM gaseous.RomMediaGroup LEFT JOIN GameState ON RomMediaGroup.Id = GameState.RomId AND GameState.IsMediaGroup = 1 AND GameState.UserId = @userid WHERE RomMediaGroup.Id=@id;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", Id },
{ "userid", userid }
};
DataTable dataTable = db.ExecuteCMD(sql, dbDict);
@@ -75,12 +78,15 @@ namespace gaseous_server.Classes
}
}
public static List<GameRomMediaGroupItem> GetMediaGroupsFromGameId(long GameId)
public static List<GameRomMediaGroupItem> GetMediaGroupsFromGameId(long GameId, string userid = "")
{
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT * FROM RomMediaGroup WHERE GameId=@gameid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("gameid", GameId);
string sql = "SELECT DISTINCT RomMediaGroup.*, GameState.RomId AS GameStateRomId FROM gaseous.RomMediaGroup LEFT JOIN GameState ON RomMediaGroup.Id = GameState.RomId AND GameState.IsMediaGroup = 1 AND GameState.UserId = @userid WHERE RomMediaGroup.GameId=@gameid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "gameid", GameId },
{ "userid", userid }
};
DataTable dataTable = db.ExecuteCMD(sql, dbDict);
@@ -156,7 +162,7 @@ namespace gaseous_server.Classes
public static void DeleteMediaGroup(long Id)
{
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "DELETE FROM RomMediaGroup WHERE Id=@id;";
string sql = "DELETE FROM RomMediaGroup WHERE Id=@id; DELETE FROM GameState WHERE RomId=@id AND IsMediaGroup=1;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", Id);
db.ExecuteCMD(sql, dbDict);
@@ -170,6 +176,15 @@ namespace gaseous_server.Classes
internal static GameRomMediaGroupItem BuildMediaGroupFromRow(DataRow row)
{
bool hasSaveStates = false;
if (row.Table.Columns.Contains("GameStateRomId"))
{
if (row["GameStateRomId"] != DBNull.Value)
{
hasSaveStates = true;
}
}
GameRomMediaGroupItem mediaGroupItem = new GameRomMediaGroupItem();
mediaGroupItem.Id = (long)row["Id"];
mediaGroupItem.Status = (GameRomMediaGroupItem.GroupBuildStatus)row["Status"];
@@ -177,6 +192,7 @@ namespace gaseous_server.Classes
mediaGroupItem.GameId = (long)row["GameId"];
mediaGroupItem.RomIds = new List<long>();
mediaGroupItem.Roms = new List<Roms.GameRomItem>();
mediaGroupItem.HasSaveStates = hasSaveStates;
// get members
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
@@ -397,6 +413,7 @@ namespace gaseous_server.Classes
public Models.PlatformMapping.PlatformMapItem.WebEmulatorItem? Emulator { get; set; }
public List<long> RomIds { get; set; }
public List<Roms.GameRomItem> Roms { get; set; }
public bool HasSaveStates { get; set; } = false;
private GroupBuildStatus _Status { get; set; }
public GroupBuildStatus Status {
get

View File

@@ -15,7 +15,7 @@ namespace gaseous_server.Classes
{}
}
public static GameRomObject GetRoms(long GameId, long PlatformId = -1, string NameSearch = "", int pageNumber = 0, int pageSize = 0)
public static GameRomObject GetRoms(long GameId, long PlatformId = -1, string NameSearch = "", int pageNumber = 0, int pageSize = 0, string userid = "")
{
GameRomObject GameRoms = new GameRomObject();
@@ -25,6 +25,7 @@ namespace gaseous_server.Classes
string sqlPlatform = "";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", GameId);
dbDict.Add("userid", userid);
string NameSearchWhere = "";
if (NameSearch.Length > 0)
@@ -38,13 +39,13 @@ namespace gaseous_server.Classes
if (PlatformId == -1) {
// data query
sql = "SELECT Games_Roms.*, Platform.`Name` AS platformname FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id WHERE Games_Roms.GameId = @id" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
sql = "SELECT DISTINCT Games_Roms.*, Platform.`Name` AS platformname, GameState.RomId AS SavedStateRomId FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id LEFT JOIN GameState ON (Games_Roms.Id = GameState.RomId AND GameState.UserId = @userid AND GameState.IsMediaGroup = 0) WHERE Games_Roms.GameId = @id" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
// count query
sqlCount = "SELECT COUNT(Games_Roms.Id) AS RomCount FROM Games_Roms WHERE Games_Roms.GameId = @id" + NameSearchWhere + ";";
} else {
// data query
sql = "SELECT Games_Roms.*, Platform.`Name` AS platformname FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id WHERE Games_Roms.GameId = @id AND Games_Roms.PlatformId = @platformid" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
sql = "SELECT DISTINCT Games_Roms.*, Platform.`Name` AS platformname, GameState.RomId AS SavedStateRomId FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id LEFT JOIN GameState ON (Games_Roms.Id = GameState.RomId AND GameState.UserId = @userid AND GameState.IsMediaGroup = 0) WHERE Games_Roms.GameId = @id AND Games_Roms.PlatformId = @platformid" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
// count query
sqlCount = "SELECT COUNT(Games_Roms.Id) AS RomCount FROM Games_Roms WHERE Games_Roms.GameId = @id AND Games_Roms.PlatformId = @platformid" + NameSearchWhere + ";";
@@ -131,7 +132,7 @@ namespace gaseous_server.Classes
}
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "DELETE FROM Games_Roms WHERE Id = @id";
string sql = "DELETE FROM Games_Roms WHERE Id = @id; DELETE FROM GameState WHERE RomId = @id;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", RomId);
db.ExecuteCMD(sql, dbDict);
@@ -140,6 +141,15 @@ namespace gaseous_server.Classes
private static GameRomItem BuildRom(DataRow romDR)
{
bool hasSaveStates = false;
if (romDR.Table.Columns.Contains("SavedStateRomId"))
{
if (romDR["SavedStateRomId"] != DBNull.Value)
{
hasSaveStates = true;
}
}
GameRomItem romItem = new GameRomItem
{
Id = (long)romDR["id"],
@@ -159,6 +169,7 @@ namespace gaseous_server.Classes
Path = (string)romDR["path"],
SignatureSource = (gaseous_server.Models.Signatures_Games.RomItem.SignatureSourceType)(Int32)romDR["metadatasource"],
SignatureSourceGameTitle = (string)Common.ReturnValueIfNull(romDR["MetadataGameName"], ""),
HasSaveStates = hasSaveStates,
Library = GameLibrary.GetLibrary((int)romDR["LibraryId"])
};
@@ -191,6 +202,7 @@ namespace gaseous_server.Classes
public long GameId { get; set; }
public string? Path { get; set; }
public string? SignatureSourceGameTitle { get; set;}
public bool HasSaveStates { get; set; } = false;
public GameLibrary.LibraryItem Library { get; set; }
}
}

View File

@@ -6,12 +6,14 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Authentication;
using gaseous_server.Classes;
using gaseous_server.Classes.Metadata;
using gaseous_server.Models;
using IGDB.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.Scripting;
using static gaseous_server.Classes.Metadata.AgeRatings;
@@ -25,6 +27,18 @@ namespace gaseous_server.Controllers
[ApiController]
public class GamesController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public GamesController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager
)
{
_userManager = userManager;
_signInManager = signInManager;
}
[MapToApiVersion("1.0")]
[HttpGet]
[ProducesResponseType(typeof(List<Game>), StatusCodes.Status200OK)]
@@ -830,13 +844,15 @@ namespace gaseous_server.Controllers
[ProducesResponseType(typeof(Classes.Roms.GameRomObject), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
//[ResponseCache(CacheProfileName = "5Minute")]
public ActionResult GameRom(long GameId, int pageNumber = 0, int pageSize = 0, long PlatformId = -1, string NameSearch = "")
public async Task<ActionResult> GameRomAsync(long GameId, int pageNumber = 0, int pageSize = 0, long PlatformId = -1, string NameSearch = "")
{
var user = await _userManager.GetUserAsync(User);
try
{
Game gameObject = Classes.Metadata.Games.GetGame(GameId, false, false, false);
return Ok(Classes.Roms.GetRoms(GameId, PlatformId, NameSearch, pageNumber, pageSize));
return Ok(Classes.Roms.GetRoms(GameId, PlatformId, NameSearch, pageNumber, pageSize, user.Id));
}
catch
{
@@ -1018,13 +1034,15 @@ namespace gaseous_server.Controllers
[ProducesResponseType(typeof(Classes.RomMediaGroup.GameRomMediaGroupItem), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
//[ResponseCache(CacheProfileName = "5Minute")]
public ActionResult GameRomGroup(long GameId, long RomGroupId)
public async Task<ActionResult> GameRomGroupAsync(long GameId, long RomGroupId)
{
var user = await _userManager.GetUserAsync(User);
try
{
Game gameObject = Classes.Metadata.Games.GetGame(GameId, false, false, false);
Classes.RomMediaGroup.GameRomMediaGroupItem rom = Classes.RomMediaGroup.GetMediaGroup(RomGroupId);
Classes.RomMediaGroup.GameRomMediaGroupItem rom = Classes.RomMediaGroup.GetMediaGroup(RomGroupId, user.Id);
if (rom.GameId == GameId)
{
return Ok(rom);
@@ -1047,15 +1065,17 @@ namespace gaseous_server.Controllers
[Route("{GameId}/romgroup")]
[ProducesResponseType(typeof(List<RomMediaGroup.GameRomMediaGroupItem>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GetGameRomGroup(long GameId)
public async Task<ActionResult> GetGameRomGroupAsync(long GameId)
{
var user = await _userManager.GetUserAsync(User);
try
{
Game gameObject = Classes.Metadata.Games.GetGame(GameId, false, false, false);
try
{
return Ok(RomMediaGroup.GetMediaGroupsFromGameId(GameId));
return Ok(RomMediaGroup.GetMediaGroupsFromGameId(GameId, user.Id));
}
catch (Exception ex)
{
@@ -1105,13 +1125,15 @@ namespace gaseous_server.Controllers
[Route("{GameId}/romgroup/{RomId}")]
[ProducesResponseType(typeof(Classes.RomMediaGroup.GameRomMediaGroupItem), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GameRomGroupMembers(long GameId, long RomGroupId, [FromBody] List<long> RomIds)
public async Task<ActionResult> GameRomGroupMembersAsync(long GameId, long RomGroupId, [FromBody] List<long> RomIds)
{
var user = await _userManager.GetUserAsync(User);
try
{
Game gameObject = Classes.Metadata.Games.GetGame(GameId, false, false, false);
Classes.RomMediaGroup.GameRomMediaGroupItem rom = Classes.RomMediaGroup.GetMediaGroup(RomGroupId);
Classes.RomMediaGroup.GameRomMediaGroupItem rom = Classes.RomMediaGroup.GetMediaGroup(RomGroupId, user.Id);
if (rom.GameId == GameId)
{
rom = Classes.RomMediaGroup.EditMediaGroup(RomGroupId, RomIds);

View File

@@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.AspNetCore.Authorization;
using System.Text;
namespace gaseous_server.Controllers
{
@@ -37,6 +38,32 @@ namespace gaseous_server.Controllers
return Ok(PlatformMapping.PlatformMap);
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Route("PlatformMap.json")]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DownloadPlatformMap()
{
string srcJson = Newtonsoft.Json.JsonConvert.SerializeObject(PlatformMapping.PlatformMap, Newtonsoft.Json.Formatting.Indented);
string filename = "PlatformMap.json";
byte[] bytes = Encoding.UTF8.GetBytes(srcJson);
string contentType = "application/json";
var cd = new System.Net.Mime.ContentDisposition
{
FileName = filename,
Inline = true,
DispositionType = "attachment"
};
Response.Headers.Add("Content-Disposition", cd.ToString());
return File(bytes, contentType);
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]

View File

@@ -85,7 +85,7 @@ namespace gaseous_server.Controllers.v1_1
model.GameAgeRating.IncludeUnrated = false;
}
return Ok(GetGames(model, pageNumber, pageSize));
return Ok(GetGames(model, user.Id, pageNumber, pageSize));
}
else
{
@@ -144,6 +144,7 @@ namespace gaseous_server.Controllers.v1_1
public GameRatingItem GameRating { get; set; } = new GameRatingItem();
public GameAgeRatingItem GameAgeRating { get; set; } = new GameAgeRatingItem();
public GameSortingItem Sorting { get; set; } = new GameSortingItem();
public bool HasSavedGame { get; set; }
public class GameRatingItem
@@ -181,11 +182,12 @@ namespace gaseous_server.Controllers.v1_1
}
}
public static GameReturnPackage GetGames(GameSearchModel model, int pageNumber = 0, int pageSize = 0)
public static GameReturnPackage GetGames(GameSearchModel model, string userid, int pageNumber = 0, int pageSize = 0)
{
string whereClause = "";
string havingClause = "";
Dictionary<string, object> whereParams = new Dictionary<string, object>();
whereParams.Add("userid", userid);
List<string> whereClauses = new List<string>();
List<string> havingClauses = new List<string>();
@@ -202,6 +204,12 @@ namespace gaseous_server.Controllers.v1_1
whereParams.Add("@Name", "(*" + model.Name + "*) (" + model.Name + ") ");
}
if (model.HasSavedGame == true)
{
string hasSavesTemp = "(RomSavedStates.RomSaveCount IS NOT NULL OR RomGroupSavedStates.MediaGroupSaveCount IS NOT NULL)";
whereClauses.Add(hasSavesTemp);
}
if (model.GameRating != null)
{
List<string> ratingClauses = new List<string>();
@@ -444,11 +452,71 @@ namespace gaseous_server.Controllers.v1_1
string orderByClause = "ORDER BY `" + orderByField + "` " + orderByOrder;
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
//string sql = "SELECT DISTINCT view_Games.* FROM view_Games LEFT JOIN Relation_Game_Platforms ON view_Games.Id = Relation_Game_Platforms.GameId AND (Relation_Game_Platforms.PlatformsId IN (SELECT DISTINCT PlatformId FROM Games_Roms WHERE Games_Roms.GameId = view_Games.Id)) LEFT JOIN Relation_Game_Genres ON view_Games.Id = Relation_Game_Genres.GameId LEFT JOIN Relation_Game_GameModes ON view_Games.Id = Relation_Game_GameModes.GameId LEFT JOIN Relation_Game_PlayerPerspectives ON view_Games.Id = Relation_Game_PlayerPerspectives.GameId LEFT JOIN Relation_Game_Themes ON view_Games.Id = Relation_Game_Themes.GameId " + whereClause + " " + havingClause + " " + orderByClause;
string sql = "SELECT DISTINCT Game.Id, Game.`Name`, Game.NameThe, Game.PlatformId, Game.TotalRating, Game.TotalRatingCount, Game.Cover, Game.Artworks, Game.FirstReleaseDate, Game.Category, Game.ParentGame, Game.AgeRatings, Game.AgeGroupId, Game.RomCount FROM (SELECT DISTINCT Game.*, CASE WHEN Game.`Name` LIKE 'The %' THEN CONCAT(TRIM(SUBSTR(Game.`Name` FROM 4)), ', The') ELSE Game.`Name` END AS NameThe, Games_Roms.PlatformId, AgeGroup.AgeGroupId, COUNT(Games_Roms.Id) AS RomCount FROM Game LEFT JOIN AgeGroup ON Game.Id = AgeGroup.GameId LEFT JOIN Games_Roms ON Game.Id = Games_Roms.GameId" + platformWhereClause + " LEFT JOIN AlternativeName ON Game.Id = AlternativeName.Game " + nameWhereClause + " GROUP BY Game.Id HAVING RomCount > 0) Game LEFT JOIN Relation_Game_Genres ON Game.Id = Relation_Game_Genres.GameId LEFT JOIN Relation_Game_GameModes ON Game.Id = Relation_Game_GameModes.GameId LEFT JOIN Relation_Game_PlayerPerspectives ON Game.Id = Relation_Game_PlayerPerspectives.GameId LEFT JOIN Relation_Game_Themes ON Game.Id = Relation_Game_Themes.GameId " + whereClause + " " + havingClause + " " + orderByClause;
List<IGDB.Models.Game> RetVal = new List<IGDB.Models.Game>();
string sql = @"
SELECT DISTINCT
Game.Id,
Game.`Name`,
Game.NameThe,
Game.PlatformId,
Game.TotalRating,
Game.TotalRatingCount,
Game.Cover,
Game.Artworks,
Game.FirstReleaseDate,
Game.Category,
Game.ParentGame,
Game.AgeRatings,
Game.AgeGroupId,
Game.RomCount,
RomSavedStates.RomSaveCount,
RomGroupSavedStates.MediaGroupSaveCount
FROM
(SELECT DISTINCT
Game.*,
CASE
WHEN Game.`Name` LIKE 'The %' THEN CONCAT(TRIM(SUBSTR(Game.`Name` FROM 4)), ', The')
ELSE Game.`Name`
END AS NameThe,
Games_Roms.PlatformId,
AgeGroup.AgeGroupId,
COUNT(Games_Roms.Id) AS RomCount
FROM
Game
LEFT JOIN AgeGroup ON Game.Id = AgeGroup.GameId
LEFT JOIN Games_Roms ON Game.Id = Games_Roms.GameId" + platformWhereClause + @"
LEFT JOIN AlternativeName ON Game.Id = AlternativeName.Game " + nameWhereClause + @"
GROUP BY Game.Id
HAVING RomCount > 0) Game
LEFT JOIN
(SELECT
Games_Roms.GameId, COUNT(GameState.Id) AS RomSaveCount
FROM
GameState
JOIN Games_Roms ON GameState.RomId = Games_Roms.Id
WHERE
GameState.IsMediaGroup = 0
AND GameState.UserId = @userid
GROUP BY Games_Roms.GameId) RomSavedStates ON Game.Id = RomSavedStates.GameId
LEFT JOIN
(SELECT
RomMediaGroup.GameId,
COUNT(RomMediaGroup.GameId) AS MediaGroupSaveCount
FROM
RomMediaGroup
JOIN GameState ON RomMediaGroup.Id = GameState.RomId
AND GameState.IsMediaGroup = 1
AND GameState.UserId = @userid
GROUP BY RomMediaGroup.GameId) RomGroupSavedStates ON Game.Id = RomGroupSavedStates.GameId
LEFT JOIN
Relation_Game_Genres ON Game.Id = Relation_Game_Genres.GameId
LEFT JOIN
Relation_Game_GameModes ON Game.Id = Relation_Game_GameModes.GameId
LEFT JOIN
Relation_Game_PlayerPerspectives ON Game.Id = Relation_Game_PlayerPerspectives.GameId
LEFT JOIN
Relation_Game_Themes ON Game.Id = Relation_Game_Themes.GameId " + whereClause + " " + havingClause + " " + orderByClause;
List<Games.MinimalGameItem> RetVal = new List<Games.MinimalGameItem>();
DataTable dbResponse = db.ExecuteCMD(sql, whereParams);
@@ -465,10 +533,24 @@ namespace gaseous_server.Controllers.v1_1
}
Game retGame = Storage.BuildCacheObject<Game>(new Game() , dbResponse.Rows[i]);
RetVal.Add(retGame);
Games.MinimalGameItem retMinGame = new Games.MinimalGameItem(retGame);
if (dbResponse.Rows[i]["RomSaveCount"] != DBNull.Value || dbResponse.Rows[i]["MediaGroupSaveCount"] != DBNull.Value)
{
retMinGame.HasSavedGame = true;
}
else
{
retMinGame.HasSavedGame = false;
}
RetVal.Add(retMinGame);
}
GameReturnPackage gameReturn = new GameReturnPackage(RecordCount, RetVal);
GameReturnPackage gameReturn = new GameReturnPackage
{
Count = RecordCount,
Games = RetVal
};
return gameReturn;
}

View File

@@ -0,0 +1,279 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using gaseous_server.Models;
using gaseous_server.Classes;
using Authentication;
using Microsoft.AspNetCore.Identity;
using System.Data;
namespace gaseous_server.Controllers.v1_1
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("1.1")]
[ApiController]
public class StateManagerController: ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public StateManagerController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager
)
{
_userManager = userManager;
_signInManager = signInManager;
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpPost]
[Authorize]
[ProducesResponseType(typeof(Models.GameStateItem), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}")]
public async Task<ActionResult> SaveStateAsync(long RomId, UploadStateModel uploadState, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "INSERT INTO GameState (UserId, RomId, IsMediaGroup, StateDateTime, Name, Screenshot, State) VALUES (@userid, @romid, @ismediagroup, @statedatetime, @name, @screenshot, @state); SELECT LAST_INSERT_ID();";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "userid", user.Id },
{ "romid", RomId },
{ "ismediagroup", IsMediaGroup },
{ "statedatetime", DateTime.UtcNow },
{ "name", "" },
{ "screenshot", uploadState.ScreenshotByteArray },
{ "state", uploadState.StateByteArray }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
return Ok(await GetStateAsync(RomId, (long)(ulong)data.Rows[0][0], IsMediaGroup));
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(List<Models.GameStateItem>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}")]
public async Task<ActionResult> GetAllStateAsync(long RomId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT Id, StateDateTime, `Name`, Screenshot FROM GameState WHERE RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid ORDER BY StateDateTime DESC;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
List<Models.GameStateItem> gameStates = new List<GameStateItem>();
foreach (DataRow row in data.Rows)
{
gameStates.Add(BuildGameStateItem(row));
}
return Ok(gameStates);
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(Models.GameStateItem), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}")]
public async Task<ActionResult> GetStateAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT Id, StateDateTime, `Name`, Screenshot FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
if (data.Rows.Count == 0)
{
// invalid match - return not found
return NotFound();
}
else
{
GameStateItem stateItem = BuildGameStateItem(data.Rows[0]);
return Ok(stateItem);
}
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpDelete]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}")]
public async Task<ActionResult> DeleteStateAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "DELETE FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
db.ExecuteNonQuery(sql, dbDict);
return Ok();
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpPut]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}")]
public async Task<ActionResult> EditStateAsync(long RomId, long StateId, GameStateItemUpdateModel model, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "UPDATE GameState SET `Name` = @name WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup },
{ "name", model.Name }
};
db.ExecuteNonQuery(sql, dbDict);
return Ok();
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}/Screenshot/")]
[Route("{RomId}/{StateId}/Screenshot/image.png")]
public async Task<ActionResult> GetStateScreenshotAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT Screenshot FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
if (data.Rows.Count == 0)
{
// invalid match - return not found
return NotFound();
}
else
{
string filename = "image.jpg";
byte[] bytes = (byte[])data.Rows[0][0];
string contentType = "image/png";
var cd = new System.Net.Mime.ContentDisposition
{
FileName = filename,
Inline = true,
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("Cache-Control", "public, max-age=604800");
return File(bytes, contentType);
}
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}/State/")]
[Route("{RomId}/{StateId}/State/savestate.state")]
public async Task<ActionResult> GetStateDataAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT State FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
if (data.Rows.Count == 0)
{
// invalid match - return not found
return NotFound();
}
else
{
string filename = "savestate.state";
byte[] bytes = (byte[])data.Rows[0][0];
string contentType = "application/octet-stream";
var cd = new System.Net.Mime.ContentDisposition
{
FileName = filename,
Inline = true,
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("Cache-Control", "public, max-age=604800");
return File(bytes, contentType);
}
}
private Models.GameStateItem BuildGameStateItem(DataRow dr)
{
bool HasScreenshot = true;
if (dr["Screenshot"] == DBNull.Value)
{
HasScreenshot = false;
}
GameStateItem stateItem = new GameStateItem
{
Id = (long)dr["Id"],
Name = (string)dr["Name"],
SaveTime = DateTime.Parse(((DateTime)dr["StateDateTime"]).ToString("yyyy-MM-ddThh:mm:ss") + 'Z'),
HasScreenshot = HasScreenshot
};
return stateItem;
}
}
}

View File

@@ -0,0 +1,35 @@
namespace gaseous_server.Models
{
public class UploadStateModel
{
public string ScreenshotByteArrayBase64 { get; set; }
public string StateByteArrayBase64 { get; set; }
public byte[] ScreenshotByteArray
{
get
{
return Convert.FromBase64String(ScreenshotByteArrayBase64);
}
}
public byte[] StateByteArray
{
get
{
return Convert.FromBase64String(StateByteArrayBase64);
}
}
}
public class GameStateItem
{
public long Id { get; set; }
public string Name { get; set; } = "";
public DateTime SaveTime { get; set; }
public bool HasScreenshot { get; set; }
}
public class GameStateItemUpdateModel
{
public string Name { get; set; } = "";
}
}

View File

@@ -29,6 +29,8 @@ namespace gaseous_server.Models
}
}
public bool HasSavedGame { get; set; } = false;
public IGDB.Models.Cover? CoverItem
{
get
@@ -46,49 +48,5 @@ namespace gaseous_server.Models
return null;
}
}
// public List<IGDB.Models.Artwork>? ArtworksItem
// {
// get
// {
// if (this.Artworks != null)
// {
// if (this.Artworks.Ids != null)
// {
// List<IGDB.Models.Artwork> artworks = new List<IGDB.Models.Artwork>();
// foreach (long id in this.Artworks.Ids)
// {
// artworks.Add(gaseous_server.Classes.Metadata.Artworks.GetArtwork(id, Config.LibraryConfiguration.LibraryMetadataDirectory_Game(this), false));
// }
// return artworks;
// }
// }
// return null;
// }
// }
// public List<IGDB.Models.Screenshot>? ScreenshotsItem
// {
// get
// {
// if (this.Screenshots != null)
// {
// if (this.Screenshots.Ids != null)
// {
// List<IGDB.Models.Screenshot> screenshots = new List<IGDB.Models.Screenshot>();
// foreach (long id in this.Screenshots.Ids)
// {
// screenshots.Add(gaseous_server.Classes.Metadata.Screenshots.GetScreenshot(id, Config.LibraryConfiguration.LibraryMetadataDirectory_Game(this), false));
// }
// return screenshots;
// }
// }
// return null;
// }
// }
}
}

View File

@@ -0,0 +1,11 @@
CREATE TABLE `GameState` (
`Id` BIGINT NOT NULL AUTO_INCREMENT,
`UserId` VARCHAR(45) NULL,
`RomId` BIGINT NULL,
`IsMediaGroup` INT NULL,
`StateDateTime` DATETIME NULL,
`Name` VARCHAR(100) NULL,
`Screenshot` LONGBLOB NULL,
`State` LONGBLOB NULL,
PRIMARY KEY (`Id`),
INDEX `idx_UserId` (`UserId` ASC) VISIBLE);

View File

@@ -59,6 +59,7 @@
<None Remove="Support\Database\MySQL\gaseous-1012.sql" />
<None Remove="Support\Database\MySQL\gaseous-1013.sql" />
<None Remove="Support\Database\MySQL\gaseous-1014.sql" />
<None Remove="Support\Database\MySQL\gaseous-1015.sql" />
<None Remove="Classes\Metadata\" />
</ItemGroup>
<ItemGroup>
@@ -95,5 +96,6 @@
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1012.sql" />
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1013.sql" />
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1014.sql" />
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1015.sql" />
</ItemGroup>
</Project>

Binary file not shown.

View File

@@ -0,0 +1,73 @@
<!-- <div style='width:640px;height:480px;max-width:100%'> -->
<div style='width:100%;height:100%;'>
<div id='game'></div>
</div>
<script type='text/javascript'>
EJS_player = '#game';
// Can also be fceumm or nestopia
EJS_core = getQueryString('core', 'string');
// Lightgun
EJS_lightgun = false; // can be true or false
// URL to BIOS file
EJS_biosUrl = emuBios;
// URL to Game rom
EJS_gameUrl = decodeURIComponent(getQueryString('rompath', 'string'));
// load state if defined
if (StateUrl) {
console.log('Loading saved state from: ' + StateUrl);
EJS_loadStateURL = StateUrl;
EJS_startOnLoaded = true;
}
// Path to the data directory
EJS_pathtodata = '/emulators/EmulatorJS/data/';
EJS_DEBUG_XX = false;
EJS_backgroundImage = emuBackground;
EJS_backgroundBlur = true;
EJS_fullscreenOnLoaded = false;
EJS_gameName = emuGameTitle;
EJS_threads = false;
EJS_onSaveState = function(e) {
var returnValue = {
"ScreenshotByteArrayBase64": btoa(Uint8ToString(e.screenshot)),
"StateByteArrayBase64": btoa(Uint8ToString(e.state))
};
var url = '/api/v1.1/StateManager/' + romId + '?IsMediaGroup=' + IsMediaGroup;
ajaxCall(
url,
'POST',
function (result) {
console.log("Upload complete");
console.log(result);
displayNotification('State Saved', 'Game state has been saved.', '/api/v1.1/StateManager/' + romId + '/' + result.value.id + '/Screenshot/image.png?IsMediaGroup=' + IsMediaGroup);
},
function (error) {
console.log("An error occurred");
console.log(error);
},
JSON.stringify(returnValue)
);
returnValue = undefined;
}
EJS_onLoadState = function(e) {
showDialog('emulatorloadstate', { "romId": romId, "IsMediaGroup": IsMediaGroup });
}
</script>
<script src='/emulators/EmulatorJS/data/loader.js'></script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<script src="/api/v1.1/System/VersionFile"></script>
<link type="text/css" rel="stylesheet" href="/styles/style.css" dat-href="/styles/style.css" />
<link type="text/css" rel="stylesheet" href="/styles/notifications.css" dat-href="/styles/notifications.css" />
<script src="/scripts/jquery-3.6.0.min.js"></script>
<script src="/scripts/moment-with-locales.min.js"></script>
<link href="/styles/select2.min.css" rel="stylesheet" />
@@ -14,6 +15,7 @@
<script src="/scripts/dropzone.min.js"></script>
<script src="/scripts/simpleUpload.min.js"></script>
<script src="/scripts/main.js" type="text/javascript"></script>
<script src="/scripts/notifications.js" type="text/javascript"></script>
<script src="/scripts/filterformating.js" type="text/javascript"></script>
<script src="/scripts/gamesformating.js" type="text/javascript"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
@@ -43,6 +45,9 @@
</script>
</head>
<body>
<!-- Notifications -->
<div id="notifications_target"></div>
<div id="banner_icon" onclick="window.location.href = '/index.html';">
<img src="/images/logo.png" alt="Gaseous" id="banner_icon_image" />
</div>

View File

@@ -1,35 +0,0 @@
<!-- <div style='width:640px;height:480px;max-width:100%'> -->
<div style='width:100%;height:100%;'>
<div id='game'></div>
</div>
<script type='text/javascript'>
EJS_player = '#game';
// Can also be fceumm or nestopia
EJS_core = getQueryString('core', 'string');
// Lightgun
EJS_lightgun = false; // can be true or false
// URL to BIOS file
EJS_biosUrl = emuBios;
// URL to Game rom
EJS_gameUrl = decodeURIComponent(getQueryString('rompath', 'string'));
// Path to the data directory
EJS_pathtodata = '/emulators/EmulatorJS/data/';
EJS_DEBUG_XX = false;
EJS_startOnLoaded = false;
EJS_backgroundImage = emuBackground;
EJS_backgroundBlur = true;
EJS_gameName = emuGameTitle;
EJS_threads = false;
</script>
<script src='/emulators/EmulatorJS/data/loader.js'></script>

View File

@@ -0,0 +1,159 @@
<div id="saved_states">
</div>
<script text="text/javascript">
document.getElementById('modal-heading').innerHTML = "Load saved state";
console.log(modalVariables);
var statesUrl = '/api/v1.1/StateManager/' + modalVariables.romId + '?IsMediaGroup=' + modalVariables.IsMediaGroup;
console.log(statesUrl);
function LoadStates() {
ajaxCall(
statesUrl,
'GET',
function(result) {
var statesBox = document.getElementById('saved_states');
statesBox.innerHTML = '';
for (var i = 0; i < result.length; i++) {
var stateBox = document.createElement('div');
stateBox.id = 'stateBox_' + result[i].id;
stateBox.className = 'saved_state_box';
// screenshot panel
var stateImageBox = document.createElement('div');
stateImageBox.id = 'stateImageBox_' + result[i].id;
stateImageBox.className = 'saved_state_image_box';
if (result[i].hasScreenshot == true) {
var stateImage = document.createElement('img');
stateImage.className = 'saved_state_image_image';
stateImage.src = '/api/v1.1/StateManager/' + modalVariables.romId + '/' + result[i].id + '/Screenshot/image.png?IsMediaGroup=' + modalVariables.IsMediaGroup;
stateImageBox.appendChild(stateImage);
}
stateBox.appendChild(stateImageBox);
// main panel
var stateMainPanel = document.createElement('div');
stateMainPanel.id = 'stateMainPanel_' + result[i].id;
stateMainPanel.className = 'saved_state_main_box';
var stateName = document.createElement('input');
stateName.id = 'stateName_' + result[i].id;
stateName.type = 'text';
stateName.className = 'saved_state_name';
stateName.setAttribute('onblur', 'UpdateStateSave(' + result[i].id + ', ' + modalVariables.IsMediaGroup + ');');
if (result[i].name) {
stateName.value = result[i].name;
} else {
stateName.setAttribute('placeholder', "Untitled");
}
stateMainPanel.appendChild(stateName);
var stateTime = document.createElement('div');
stateTime.id = 'stateTime_' + result[i].id;
stateTime.className = 'saved_state_date';
stateTime.innerHTML = moment(result[i].saveTime).format("YYYY-MM-DD h:mm:ss a");
stateMainPanel.appendChild(stateTime);
var stateControls = document.createElement('div');
stateControls.id = 'stateControls_' + result[i].id;
stateControls.className = 'saved_state_controls';
var stateControlsLaunch= document.createElement('span');
stateControlsLaunch.id = 'stateControlsLaunch_' + result[i].id;
stateControlsLaunch.className = 'romstart';
var emulatorTarget = '/index.html?page=emulator&engine=@engine&core=@core&platformid=@platformid&gameid=@gameid&romid=@romid&mediagroup=@mediagroup&rompath=@rompath&stateid=' + result[i].id;
switch (getQueryString('page', 'string')) {
case 'emulator':
var mediagroupint = 0;
if (modalVariables.IsMediaGroup == true) {
mediagroupint = 1;
}
emulatorTarget = emulatorTarget.replaceAll('@engine', getQueryString('engine', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@core', getQueryString('core', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@platformid', getQueryString('platformid', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@gameid', getQueryString('gameid', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@romid', getQueryString('romid', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@mediagroup', mediagroupint);
emulatorTarget = emulatorTarget.replaceAll('@rompath', getQueryString('rompath', 'string'));
stateControlsLaunch.setAttribute("onclick", 'window.location.replace("' + emulatorTarget + '")');
break;
case 'game':
console.log(modalVariables);
emulatorTarget = emulatorTarget.replaceAll('@engine', modalVariables.engine);
emulatorTarget = emulatorTarget.replaceAll('@core', modalVariables.core);
emulatorTarget = emulatorTarget.replaceAll('@platformid', modalVariables.platformid);
emulatorTarget = emulatorTarget.replaceAll('@gameid', modalVariables.gameid);
emulatorTarget = emulatorTarget.replaceAll('@romid', modalVariables.romId);
emulatorTarget = emulatorTarget.replaceAll('@mediagroup', modalVariables.mediagroup);
emulatorTarget = emulatorTarget.replaceAll('@rompath', modalVariables.rompath);
stateControlsLaunch.setAttribute("onclick", 'window.location.href = "' + emulatorTarget + '"');
break;
}
stateControlsLaunch.innerHTML = 'Launch';
stateControlsLaunch.style.float = 'right';
stateControls.appendChild(stateControlsLaunch);
var stateControlsDownload = document.createElement('a');
stateControlsDownload.id = 'stateControlsDownload_' + result[i].id;
stateControlsDownload.className = 'saved_state_buttonlink';
stateControlsDownload.href = '/api/v1.1/StateManager/' + modalVariables.romId + '/' + result[i].id + '/State/savestate.state?IsMediaGroup=' + modalVariables.IsMediaGroup;
stateControlsDownload.innerHTML = '<img src="/images/download.svg" class="banner_button_image" alt="Download" title="Download" />';
stateControls.appendChild(stateControlsDownload);
var stateControlsDelete = document.createElement('span');
stateControlsDelete.id = 'stateControlsDelete_' + result[i].id;
stateControlsDelete.className = 'saved_state_buttonlink';
stateControlsDelete.setAttribute('onclick', 'DeleteStateSave(' + result[i].id + ', ' + modalVariables.IsMediaGroup + ');');
stateControlsDelete.innerHTML = '<img src="/images/delete.svg" class="banner_button_image" alt="Delete" title="Delete" />';
stateControls.appendChild(stateControlsDelete);
stateMainPanel.appendChild(stateControls);
stateBox.appendChild(stateMainPanel);
statesBox.appendChild(stateBox);
}
}
);
}
LoadStates();
function DeleteStateSave(StateId, IsMediaGroup) {
ajaxCall(
'/api/v1.1/StateManager/' + modalVariables.romId + '/' + StateId + '?IsMediaGroup=' + IsMediaGroup,
'DELETE',
function(success) {
LoadStates();
},
function (error) {
LoadStates();
}
);
}
function UpdateStateSave(StateId, IsMediaGroup) {
var stateName = document.getElementById('stateName_' + StateId);
var model = {
"name": stateName.value
};
ajaxCall(
'/api/v1.1/StateManager/' + modalVariables.romId + '/' + StateId + '?IsMediaGroup=' + IsMediaGroup,
'PUT',
function(success) {
LoadStates();
},
function (error) {
LoadStates();
},
JSON.stringify(model)
);
}
</script>

View File

@@ -1,4 +1,4 @@
<p>Are you sure you want to delete this media group?</p>
<p>Are you sure you want to delete this media group and all associated saved states?</p>
<p><strong>Warning:</strong> This cannot be undone!</p>
<div style="width: 100%; text-align: center;">
<div style="display: inline-block; margin-right: 20px;">

View File

@@ -1,4 +1,4 @@
<p>Are you sure you want to delete this ROM?</p>
<p>Are you sure you want to delete this ROM and all associated saved states?</p>
<p><strong>Warning:</strong> This cannot be undone!</p>
<div style="width: 100%; text-align: center;">
<div style="display: inline-block; margin-right: 20px;">

View File

@@ -1,4 +1,4 @@
<p>Are you sure you want to delete the selected ROMs?</p>
<p>Are you sure you want to delete the selected ROMs and any associated saved states?</p>
<p><strong>Warning:</strong> This cannot be undone!</p>
<div style="width: 100%; text-align: center;">
<div style="display: inline-block; margin-right: 20px;">

View File

@@ -6,7 +6,15 @@
<script type="text/javascript">
var gameId = getQueryString('gameid', 'int');
var romId = getQueryString('romid', 'int');
var platformId = getQueryString('platformid', 'int');
var IsMediaGroupInt = getQueryString('mediagroup', 'int');
var IsMediaGroup = false;
if (IsMediaGroupInt == 1) { IsMediaGroup = true; }
var StateUrl = undefined;
if (getQueryString('stateid', 'int')) {
StateUrl = '/api/v1.1/StateManager/' + romId + '/' + getQueryString('stateid', 'int') + '/State/savestate.state?IsMediaGroup=' + IsMediaGroup;
}
var gameData;
var artworks = null;
var artworksPosition = 0;
@@ -47,7 +55,7 @@
switch (getQueryString('engine', 'string')) {
case 'EmulatorJS':
$('#emulator').load('/pages/EmulatorJS.html?v=' + AppVersion);
$('#emulator').load('/emulators/EmulatorJS.html?v=' + AppVersion);
break;
}
});

View File

@@ -97,7 +97,7 @@
<button value="Search" onclick="loadRoms();">Search</button>
</div>
<div class="games_library_controlblock">
<span class="games_library_label">0 ROMs</span>
<span id="games_roms_count" class="games_library_label">0 ROMs</span>
</div>
</div>
<div id="gamesummaryromscontent"></div>
@@ -476,7 +476,7 @@
mgTable.id = 'mediagrouptable';
mgTable.className = 'romtable';
mgTable.setAttribute('cellspacing', 0);
mgTable.appendChild(createTableRow(true, ['Platform', 'Images', 'Size', '', '', '']));
mgTable.appendChild(createTableRow(true, ['Platform', 'Images', 'Size', '', '', '', '']));
lastPlatform = '';
for (var i = 0; i < result.length; i++) {
@@ -484,9 +484,28 @@
// get rom details including emulator and friendly platform name
var launchButton = '';
var saveStatesButton = '';
if (mediaGroup.emulator) {
if (mediaGroup.emulator.type.length > 0) {
launchButton = '<a href="/index.html?page=emulator&engine=' + mediaGroup.emulator.type + '&core=' + mediaGroup.emulator.core + '&platformid=' + mediaGroup.platformId + '&gameid=' + gameId + '&rompath=' + encodeURIComponent('/api/v1.1/Games/' + gameId + '/romgroup/' + mediaGroup.id + '/' + gameData.name + '(' + mediaGroup.id + ')' + '.zip') + '" class="romstart">Launch</a>';
var romPath = encodeURIComponent('/api/v1.1/Games/' + gameId + '/romgroup/' + mediaGroup.id + '/' + gameData.name + '(' + mediaGroup.id + ')' + '.zip');
if (mediaGroup.hasSaveStates == true) {
var modalVariables = {
"romId": mediaGroup.id,
"IsMediaGroup": true,
"engine": mediaGroup.emulator.type,
"core": mediaGroup.emulator.core,
"platformid": mediaGroup.platformId,
"gameid": gameId,
"mediagroup": 1,
"rompath": romPath
};
saveStatesButton = document.createElement('div');
saveStatesButton.setAttribute('onclick', 'showDialog("emulatorloadstate", ' + JSON.stringify(modalVariables) + ');');
saveStatesButton.innerHTML = '<img src="/images/SaveStates.png" class="savedstateicon" />';
}
launchButton = '<a href="/index.html?page=emulator&engine=' + mediaGroup.emulator.type + '&core=' + mediaGroup.emulator.core + '&platformid=' + mediaGroup.platformId + '&gameid=' + gameId + '&romid=' + mediaGroup.id + '&mediagroup=1&rompath=' + romPath + '" class="romstart">Launch</a>';
}
}
@@ -532,15 +551,19 @@
mediaGroup.romIds.length,
packageSize,
statusText,
saveStatesButton,
launchButtonContent,
'<div style="text-align: right;">' + downloadLink + deleteButton + '</div>'
]
mgTable.appendChild(createTableRow(false, newRow, 'romrow', 'romcell'));
var mgRowBody = document.createElement('tbody');
mgRowBody.className = 'romrow';
mgRowBody.appendChild(createTableRow(false, newRow, '', 'romcell'));
var mgRomRow = document.createElement('tr');
var mgRomCell = document.createElement('td');
mgRomCell.setAttribute('colspan', 6);
mgRomCell.setAttribute('colspan', 7);
mgRomCell.className = 'romGroupTitles';
@@ -557,7 +580,9 @@
}
mgRomCell.innerHTML = groupMemberNames.join("<br />");
mgRomRow.appendChild(mgRomCell);
mgTable.appendChild(mgRomRow);
mgRowBody.appendChild(mgRomRow);
mgTable.appendChild(mgRowBody);
}
mediaGroupDiv.appendChild(mgTable);
@@ -600,6 +625,13 @@
var gameRoms = document.getElementById('gamesummaryromscontent');
var pageSize = 20;
ajaxCall('/api/v1.1/Games/' + gameId + '/roms?pageNumber=' + pageNumber + '&pageSize=' + pageSize + '&platformId=' + selectedPlatform + nameSearchQuery, 'GET', function (result) {
var romCount = document.getElementById('games_roms_count');
if (result.count != 1) {
romCount.innerHTML = result.count + ' ROMs';
} else {
romCount.innerHTML = result.count + ' ROM';
}
if (result.gameRomItems) {
var gameRomItems = result.gameRomItems;
@@ -608,7 +640,7 @@
newTable.id = 'romtable';
newTable.className = 'romtable';
newTable.setAttribute('cellspacing', 0);
newTable.appendChild(createTableRow(true, [['<input id="rom_mastercheck" type="checkbox" onclick="selectAllChecks(); handleChecks();"/>', 'rom_checkbox_box_hidden', 'rom_edit_checkbox'], 'Name', 'Size', 'Media', '', '', '']));
newTable.appendChild(createTableRow(true, [['<input id="rom_mastercheck" type="checkbox" onclick="selectAllChecks(); handleChecks();"/>', 'rom_checkbox_box_hidden', 'rom_edit_checkbox'], 'Name', 'Size', 'Media', '', '', '', '']));
var lastPlatform = '';
for (var i = 0; i < gameRomItems.length; i++) {
@@ -622,11 +654,28 @@
newTable.appendChild(platformRow);
}
var saveStatesButton = '';
var launchButton = '';
if (result.gameRomItems[i].emulator) {
if (gameRomItems[i].emulator.type) {
if (gameRomItems[i].emulator.type.length > 0) {
launchButton = '<a href="/index.html?page=emulator&engine=' + gameRomItems[i].emulator.type + '&core=' + gameRomItems[i].emulator.core + '&platformid=' + gameRomItems[i].platformId + '&gameid=' + gameId + '&rompath=' + encodeURIComponent('/api/v1.1/Games/' + gameId + '/roms/' + gameRomItems[i].id + '/' + encodeURIComponent(gameRomItems[i].name)) + '" class="romstart">Launch</a>';
var romPath = encodeURIComponent('/api/v1.1/Games/' + gameId + '/roms/' + gameRomItems[i].id + '/' + gameRomItems[i].name);
if (gameRomItems[i].hasSaveStates == true) {
var modalVariables = {
"romId": gameRomItems[i].id,
"IsMediaGroup": false,
"engine": gameRomItems[i].emulator.type,
"core": gameRomItems[i].emulator.core,
"platformid": gameRomItems[i].platformId,
"gameid": gameId,
"mediagroup": 0,
"rompath": romPath
};
saveStatesButton = document.createElement('div');
saveStatesButton.setAttribute('onclick', 'showDialog("emulatorloadstate", ' + JSON.stringify(modalVariables) + ');');
saveStatesButton.innerHTML = '<img src="/images/SaveStates.png" class="savedstateicon" />';
}
launchButton = '<a href="/index.html?page=emulator&engine=' + gameRomItems[i].emulator.type + '&core=' + gameRomItems[i].emulator.core + '&platformid=' + gameRomItems[i].platformId + '&gameid=' + gameId + '&romid=' + gameRomItems[i].id + '&mediagroup=0&rompath=' + romPath + '" class="romstart">Launch</a>';
}
}
}
@@ -637,10 +686,11 @@
formatBytes(gameRomItems[i].size, 2),
gameRomItems[i].romTypeMedia,
gameRomItems[i].mediaLabel,
saveStatesButton,
launchButton,
'<div class="properties_button" onclick="showDialog(\'rominfo\', ' + gameRomItems[i].id + ');">i</div>'
];
newTable.appendChild(createTableRow(false, newRow, 'romrow', 'romcell'));
newTable.appendChild(createTableRow(false, newRow, 'romrow romrowgamepage', 'romcell'));
}
gameRoms.appendChild(newTable);
@@ -713,15 +763,17 @@
bg.setAttribute('style', 'background-image: url("/images/gamebg' + randomInt + '.jpg"); background-position: center; background-repeat: no-repeat; background-size: cover; filter: blur(10px); -webkit-filter: blur(10px);');
}
// // load artwork
// if (game.artworks) {
// ajaxCall('/api/v1.1/games/' + gameId + '/artwork', 'GET', function (result) {
// artworks = result;
// var startPos = randomIntFromInterval(0, result.length);
// artworksPosition = startPos;
// rotateBackground();
// });
// }
// load artwork
if (!artworks) {
if (game.artworks) {
ajaxCall('/api/v1.1/games/' + gameId + '/artwork', 'GET', function (result) {
artworks = result;
var startPos = randomIntFromInterval(0, result.length);
artworksPosition = startPos;
rotateBackground();
});
}
}
}
function rotateBackground() {

View File

@@ -72,7 +72,7 @@
}
function DownloadJSON() {
window.open('/api/v1.1/PlatformMaps', '_blank');
window.location = '/api/v1.1/PlatformMaps/PlatformMap.json';
}
document.getElementById('importjson').addEventListener('click', openDialog);

View File

@@ -21,6 +21,14 @@ function formatFilterPanel(containerElement, result) {
containerPanelSearchField.id = 'filter_panel_search';
containerPanelSearchField.type = 'text';
containerPanelSearchField.placeholder = 'Search';
containerPanelSearchField.addEventListener("keypress", function(event) {
if (event.key === "Enter") {
// Cancel the default action, if needed
event.preventDefault();
// Trigger the button element with a click
executeFilter1_1();
}
});
containerPanelSearch.appendChild(containerPanelSearchField);
panel.appendChild(containerPanelSearch);
@@ -73,6 +81,8 @@ function formatFilterPanel(containerElement, result) {
panel.appendChild(containerPanelUserRating);
buildFilterPanel(panel, 'settings', 'Settings', [{ "id": "savestatesavailable", "name": "Game has save states avaialble", "gameCount": 0 }], true, true);
if (result.platforms) {
buildFilterPanel(panel, 'platform', 'Platforms', result.platforms, true, true);
}
@@ -350,6 +360,9 @@ function executeFilter1_1(pageNumber, pageSize) {
setCookie('games_library_orderby_direction_select', orderByDirectionSelect);
if (existingSearchModel == undefined || freshSearch == true) {
// search name
setCookie('filter_panel_search', document.getElementById('filter_panel_search').value);
// user ratings
var userRatingEnabled = document.getElementById('filter_panel_userrating_enabled');
@@ -386,6 +399,9 @@ function executeFilter1_1(pageNumber, pageSize) {
setCookie("filter_panel_userrating_enabled", true);
}
// save cookies for settings
GetFilterQuery1_1('settings');
// build filter model
var ratingAgeGroups = GetFilterQuery1_1('agegroupings');
var ratingIncludeUnrated = false;
@@ -395,6 +411,7 @@ function executeFilter1_1(pageNumber, pageSize) {
model = {
"Name": document.getElementById('filter_panel_search').value,
"HasSavedGame": document.getElementById('filter_panel_item_settings_checkbox_savestatesavailable').checked,
"Platform": GetFilterQuery1_1('platform'),
"Genre": GetFilterQuery1_1('genre'),
"GameMode": GetFilterQuery1_1('gamemode'),

View File

@@ -283,6 +283,14 @@ function renderGameIcon(gameObject, showTitle, showRatings, showClassification,
}
}
// add save game icon
if (gameObject.hasSavedGame == true) {
var gameSaveIcon = document.createElement('img');
gameSaveIcon.src = '/images/SaveStates.png';
gameSaveIcon.className = 'game_tile_box_savedgame savedstateicon';
gameImageBox.appendChild(gameSaveIcon);
}
if (gameObject.totalRating || displayClassification == true) {
var gameImageRatingBanner = document.createElement('div');
gameImageRatingBanner.className = 'game_tile_box_ratingbanner';

View File

@@ -495,4 +495,13 @@ function SetPreference_Local(Setting, Value) {
userProfile.userPreferences.push(model);
}
}
}
function Uint8ToString(u8a){
var CHUNK_SZ = 0x8000;
var c = [];
for (var i=0; i < u8a.length; i+=CHUNK_SZ) {
c.push(String.fromCharCode.apply(null, u8a.subarray(i, i+CHUNK_SZ)));
}
return c.join("");
}

View File

@@ -0,0 +1,58 @@
function displayNotification(heading, message, image, link) {
var noteId = Math.random().toString(36).substr(2, 9);
var noteBox = document.createElement('div');
noteBox.id = noteId;
noteBox.className = 'notification';
noteBox.style.display = 'none;'
if (link) {
noteBox.setAttribute('onclick', 'window.location.href = "' + link + '"');
} else {
noteBox.setAttribute('onclick', 'closeNotification("' + noteId + '");');
}
if (image) {
var noteImageBox = document.createElement('div');
noteImageBox.className = 'notification_imagebox';
var noteImage = document.createElement('img');
noteImage.className = 'notification_image';
noteImage.src = image;
noteImageBox.appendChild(noteImage);
noteBox.appendChild(noteImageBox);
}
var noteMessageBox = document.createElement('div');
noteMessageBox.className = 'notification_messagebox';
if (heading) {
var noteMessageHeading = document.createElement('div');
noteMessageHeading.className = 'notification_title';
noteMessageHeading.innerHTML = heading;
noteMessageBox.appendChild(noteMessageHeading);
}
var noteMessageBody = document.createElement('div');
noteMessageBody.className = 'notification_message';
noteMessageBody.innerHTML = message;
noteMessageBox.appendChild(noteMessageBody);
noteBox.appendChild(noteMessageBox);
document.getElementById('notifications_target').appendChild(noteBox);
$(noteBox).hide().fadeIn(500);
setTimeout(function() {
closeNotification(noteId);
}, 5000);
}
function closeNotification(id) {
var notificationObj = document.getElementById(id);
$(notificationObj).fadeOut(1000, function() {
notificationObj.parentElement.removeChild(notificationObj);
});
}

View File

@@ -0,0 +1,58 @@
#notification_target {
position: fixed;
top: 40px;
width: 400px;
right: 0px;
bottom: 0px;
}
.notification {
position: absolute;
display: flex;
flex-direction: column nowrap;
top: 55px;
right: 15px;
width: 350px;
border-style: solid;
border-width: 1px;
border-radius: 7px;
border-color: rgba(0, 22, 56, 0.7);
padding-left: 15px;
padding-right: 20px;
padding-top: 15px;
padding-bottom: 10px;
background-color: rgba(0, 22, 56, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 5px 5px 19px 0px rgba(0,0,0,0.44);
-webkit-box-shadow: 5px 5px 19px 0px rgba(0,0,0,0.44);
-moz-box-shadow: 5px 5px 19px 0px rgba(0,0,0,0.44);
z-index: 10;
}
.notification_imagebox {
flex-basis: 70px;
margin-bottom: 0px;
padding-bottom: 0px;
}
.notification_image {
width: 80px;
margin-right: 10px;
}
.notification_messagebox {
flex-grow: 1;
}
.notification_title {
font-weight: bold;
text-transform: uppercase;
margin-bottom: 5px;
font-size: 14px;
color: rgb(160, 160, 186);
}
.notification_message {
}

View File

@@ -213,13 +213,13 @@ h3 {
position: -webkit-sticky;
bottom: 0;
width: 180px;
height: 18px;
height: 15px;
padding: 10px;
background-color: #352879;
color: white;
text-align: center;
font-family: Commodore64;
font-size: 16px;
font-size: 13px;
}
#games_library_searchbutton:hover {
@@ -232,13 +232,13 @@ h3 {
position: -webkit-sticky;
bottom: 0;
width: 180px;
height: 18px;
height: 15px;
padding: 10px;
background-color: #646464;
color: white;
text-align: center;
font-family: Commodore64;
font-size: 16px;
font-size: 13px;
}
#games_library_resetbutton:hover {
@@ -557,6 +557,12 @@ input[id='filter_panel_userrating_max'] {
-webkit-backdrop-filter: blur(8px);
}
.game_tile_box_savedgame {
position: absolute;
top: 1px;
right: 5px;
}
.game_tile_image {
max-width: 200px;
min-width: 150px;
@@ -792,10 +798,21 @@ table .romrow:nth-child(odd) {
background: rgba(56, 56, 56, 0.3);
}
.romrow.romrowgamepage {
height: 42px;
}
.romcell {
padding: 5px;
}
.savedstateicon {
height: 24px;
width: 24px;
margin-top: 4px;
cursor: pointer;
}
th {
text-align: left;
padding: 5px;
@@ -1416,4 +1433,49 @@ button:not(.select2-selection__choice__remove):not(.ejs_menu_button):disabled {
margin-bottom: 5px;
margin-left: 5px;
margin-right: 5px;
}
#saved_states {
overflow-x: scroll;
height: 350px;
}
.saved_state_box {
display: flex;
flex-direction: column nowrap;
padding: 10px;
}
.saved_state_image_box {
flex-basis: 128px;
}
.saved_state_image_image {
width: 128px;
margin: 0px;
}
.saved_state_main_box {
flex-grow: 1;
padding-left: 20px;
padding-right: 20px;
}
.saved_state_name {
font-size: 15px;
width: 100%;
margin-bottom: 10px;
}
.saved_state_date {
color: #888;
}
.saved_state_controls {
margin-top: 10px;
text-align: left;
}
.saved_state_buttonlink {
cursor: pointer;
}