diff --git a/Assets/Img/Profile.png b/Assets/Img/Profile.png new file mode 100644 index 0000000..d4fe288 Binary files /dev/null and b/Assets/Img/Profile.png differ diff --git a/Pages/Bits/Navbar.php b/Pages/Bits/Navbar.php index 78bf0ae..67d4c6a 100644 --- a/Pages/Bits/Navbar.php +++ b/Pages/Bits/Navbar.php @@ -3,7 +3,8 @@ $NavbarPages = [ "Home" => "/", "Sarahtonin" => "/Pages/Sarahtonin.html", - "Garden" => "/Pages/Garden.html" + "Garden" => "/Pages/Garden.html", + "Profile" => "/Pages/Profile.html" ]; ?> diff --git a/Pages/Profile.php b/Pages/Profile.php new file mode 100644 index 0000000..ee61212 --- /dev/null +++ b/Pages/Profile.php @@ -0,0 +1,246 @@ + + + + + + + + + + +
+

Profile

+ +

+ Manage your Garden profile, view your + statistics, and move your account between + devices. +

+ + +
+

Player

+ +
+ + +
+ + + +
+ +

+
+
+ + +
+

Statistics

+ +
+
+ + Current Dew + + + + ... + +
+ +
+ + Lifetime Dew + + + + ... + +
+ +
+ + Plants discovered + + + + ... + +
+ +
+ + Mutations discovered + + + + ... + +
+ +
+ + Garden size + + + + ... + +
+
+
+ + +
+

Account

+ +

+ Your Account Key is the secret key used + to identify your Garden account. +

+ +

+ You can use the same Account Key on + another device to access the same + username, save, and leaderboard entry. +

+ +

+ + Anyone with your Account Key can + access your Garden account. + + Keep it private and treat it like a + password. +

+ + +
+ + Show Account Key + + +
+

+ Do not share this key with + anyone you do not trust. +

+ + + Loading... + + +
+ + + +
+ +

+
+
+
+ + +
+

Import Account

+ +

+ To use an existing Garden account on + this device, paste its Account Key + below. +

+ +

+ Importing an Account Key will replace + the account currently used by this + browser. It will not delete the old + account or its server save. +

+ +
+ + + + +
+ + + +
+ + + +

+
+
+
+ + + + + + + + diff --git a/Scripts/Garden/Account.js b/Scripts/Garden/Account.js new file mode 100644 index 0000000..610c071 --- /dev/null +++ b/Scripts/Garden/Account.js @@ -0,0 +1,581 @@ +function StartAccount() { + const AccountKeySpoiler = + document.querySelector( + ".AccountKeySpoiler" + ); + + const CopyButton = + document.getElementById( + "CopyAccountKeyButton" + ); + + const ExportButton = + document.getElementById( + "ExportAccountKeyButton" + ); + + const ImportForm = + document.getElementById( + "ImportAccountForm" + ); + + const ImportFileButton = + document.getElementById( + "ImportAccountKeyFileButton" + ); + + const ImportFileInput = + document.getElementById( + "ImportAccountKeyFile" + ); + + + AccountKeySpoiler.addEventListener( + "toggle", + RenderAccountKeySpoiler + ); + + CopyButton.addEventListener( + "click", + CopyAccountKey + ); + + ExportButton.addEventListener( + "click", + ExportAccountKey + ); + + ImportForm.addEventListener( + "submit", + SubmitAccountImport + ); + + ImportFileButton.addEventListener( + "click", + () => { + ImportFileInput.click(); + } + ); + + ImportFileInput.addEventListener( + "change", + ImportAccountKeyFile + ); +} + + +function RenderAccountKeySpoiler() { + const Spoiler = + document.querySelector( + ".AccountKeySpoiler" + ); + + const Summary = + Spoiler.querySelector( + "summary" + ); + + const Display = + document.getElementById( + "AccountKeyDisplay" + ); + + + if (Spoiler.open) { + Display.textContent = + GetSaveKey(); + + Summary.textContent = + "Hide Account Key"; + + return; + } + + + /* + * Remove the key from the page when + * the spoiler is closed again. + */ + + Display.textContent = + "Hidden"; + + Summary.textContent = + "Show Account Key"; +} + + +async function CopyAccountKey() { + const AccountKey = + GetSaveKey(); + + const Message = + document.getElementById( + "AccountKeyMessage" + ); + + + try { + if ( + navigator.clipboard !== + undefined + ) { + await navigator.clipboard.writeText( + AccountKey + ); + } else { + CopyTextFallback( + AccountKey + ); + } + + Message.textContent = + "Account Key copied."; + } catch (Error) { + console.error( + "Couldn't copy Account Key:", + Error + ); + + try { + CopyTextFallback( + AccountKey + ); + + Message.textContent = + "Account Key copied."; + } catch (FallbackError) { + console.error( + "Couldn't copy Account Key:", + FallbackError + ); + + Message.textContent = + "Couldn't copy Account Key."; + } + } +} + + +function CopyTextFallback( + Text +) { + const TextArea = + document.createElement( + "textarea" + ); + + TextArea.value = + Text; + + TextArea.setAttribute( + "readonly", + "" + ); + + TextArea.style.position = + "fixed"; + + TextArea.style.opacity = + "0"; + + + document.body.appendChild( + TextArea + ); + + TextArea.select(); + + + const Success = + document.execCommand( + "copy" + ); + + + TextArea.remove(); + + + if (!Success) { + throw new Error( + "Copy command failed." + ); + } +} + + +function ExportAccountKey() { + const AccountKey = + GetSaveKey(); + + const Message = + document.getElementById( + "AccountKeyMessage" + ); + + + try { + const File = + new Blob( + [ + AccountKey + + "\n" + ], + { + type: + "text/plain;charset=utf-8" + } + ); + + const FileUrl = + URL.createObjectURL( + File + ); + + const Link = + document.createElement( + "a" + ); + + Link.href = + FileUrl; + + Link.download = + "SarahtoninGardenAccountKey.txt"; + + + document.body.appendChild( + Link + ); + + Link.click(); + + Link.remove(); + + + URL.revokeObjectURL( + FileUrl + ); + + + Message.textContent = + "Account Key exported."; + } catch (Error) { + console.error( + "Couldn't export Account Key:", + Error + ); + + Message.textContent = + "Couldn't export Account Key."; + } +} + + +async function SubmitAccountImport( + Event +) { + Event.preventDefault(); + + const Input = + document.getElementById( + "ImportAccountKeyInput" + ); + + await ImportAccountKey( + Input.value + ); +} + + +async function ImportAccountKeyFile( + Event +) { + const File = + Event.target.files[0]; + + if (File === undefined) { + return; + } + + + const Message = + document.getElementById( + "ImportAccountMessage" + ); + + + try { + const AccountKey = + await File.text(); + + const Input = + document.getElementById( + "ImportAccountKeyInput" + ); + + Input.value = + AccountKey.trim(); + + await ImportAccountKey( + AccountKey + ); + } catch (Error) { + console.error( + "Couldn't read Account Key file:", + Error + ); + + Message.textContent = + "Couldn't read that Account Key file."; + } finally { + /* + * Allows the same file to be + * selected again later. + */ + + Event.target.value = + ""; + } +} + + +async function ImportAccountKey( + RawAccountKey +) { + const Message = + document.getElementById( + "ImportAccountMessage" + ); + + const AccountKey = + NormalizeAccountKey( + RawAccountKey + ); + + + if ( + !IsValidAccountKey( + AccountKey + ) + ) { + Message.textContent = + "That isn't a valid Account Key."; + + return; + } + + + const CurrentAccountKey = + GetSaveKey(); + + + if ( + AccountKey === + CurrentAccountKey + ) { + Message.textContent = + "You're already using that account."; + + return; + } + + + Message.textContent = + "Checking account..."; + + + /* + * Check that the key actually belongs + * to an existing Garden account before + * replacing this browser's key. + * + * This prevents a typo from silently + * creating a completely new account. + */ + + let AccountExists; + + try { + AccountExists = + await CheckAccountKeyExists( + AccountKey + ); + } catch (Error) { + console.error( + "Couldn't verify Account Key:", + Error + ); + + Message.textContent = + "Couldn't verify that account right now."; + + return; + } + + + if (!AccountExists) { + Message.textContent = + "No Garden account exists for that key."; + + return; + } + + + const Confirmed = + window.confirm( + "Import this Garden account?\n\n" + + "This browser will stop using its current " + + "account and switch to the imported one.\n\n" + + "The current account and its server save " + + "will not be deleted." + ); + + + if (!Confirmed) { + Message.textContent = + "Import cancelled."; + + return; + } + + + /* + * Try to synchronize the current + * browser save one last time before + * switching away from it. + */ + + const CurrentSave = + ReadLocalSave(); + + + if (CurrentSave !== null) { + Message.textContent = + "Saving current account..."; + + try { + await WriteRemoteSave( + CurrentSave + ); + } catch (Error) { + console.error( + "Couldn't synchronize current account:", + Error + ); + + const SwitchAnyway = + window.confirm( + "The current account couldn't be " + + "synchronized with the server.\n\n" + + "Switch accounts anyway?" + ); + + + if (!SwitchAnyway) { + Message.textContent = + "Import cancelled."; + + return; + } + } + } + + + /* + * Change identity first, then remove + * the cached save. + * + * On reload, LoadGame() sees no local + * save and downloads the imported + * account's save from the server. + */ + + localStorage.setItem( + SaveKeyName, + AccountKey + ); + + localStorage.removeItem( + LocalSaveName + ); + + + Message.textContent = + "Account imported. Reloading..."; + + + location.reload(); +} + + +function NormalizeAccountKey( + AccountKey +) { + return AccountKey + .trim() + .toLowerCase(); +} + + +function IsValidAccountKey( + AccountKey +) { + return /^[a-f0-9]{64}$/.test( + AccountKey + ); +} + + +async function CheckAccountKeyExists( + AccountKey +) { + const Response = + await fetch( + ApiUrl + "/Load.php", + { + method: "POST", + + headers: { + "Content-Type": + "application/json" + }, + + body: JSON.stringify({ + SaveKey: + AccountKey + }) + } + ); + + + if (!Response.ok) { + throw new Error( + "Load API returned HTTP " + + Response.status + ); + } + + + const Result = + await Response.json(); + + + if (!Result.Success) { + throw new Error( + Result.Error ?? + "Account lookup failed." + ); + } + + + return ( + Result.Exists === true + ); +} + + +document.addEventListener( + "DOMContentLoaded", + StartAccount +); diff --git a/Scripts/Garden/Profile.js b/Scripts/Garden/Profile.js new file mode 100644 index 0000000..660993c --- /dev/null +++ b/Scripts/Garden/Profile.js @@ -0,0 +1,268 @@ +let ProfileSave = null; + + +async function StartProfile() { + await RenderProfileUsername(); + + try { + ProfileSave = + await LoadGame(); + + RenderProfileStatistics(); + } catch (Error) { + console.error( + "Couldn't load profile statistics:", + Error + ); + + RenderProfileStatisticsError(); + } + + + const UsernameForm = + document.getElementById( + "ProfileUsernameForm" + ); + + UsernameForm.addEventListener( + "submit", + SubmitProfileUsername + ); +} + + +async function RenderProfileUsername() { + const Input = + document.getElementById( + "ProfileUsernameInput" + ); + + const Message = + document.getElementById( + "ProfileUsernameMessage" + ); + + try { + const Profile = + await GetProfile(); + + if (!Profile.Success) { + throw new Error( + Profile.Error ?? + "Couldn't load profile." + ); + } + + if ( + Profile.Username !== null + ) { + Input.value = + Profile.Username; + } + + Message.textContent = ""; + } catch (Error) { + console.error( + "Couldn't load username:", + Error + ); + + Message.textContent = + "Couldn't load username."; + } +} + + +async function SubmitProfileUsername( + Event +) { + Event.preventDefault(); + + const Input = + document.getElementById( + "ProfileUsernameInput" + ); + + const Message = + document.getElementById( + "ProfileUsernameMessage" + ); + + const Username = + Input.value.trim(); + + + if ( + Username.length < 3 || + Username.length > 24 + ) { + Message.textContent = + "Username must be between 3 and 24 characters."; + + return; + } + + + Message.textContent = + "Saving..."; + + + try { + const Result = + await SetUsername( + Username + ); + + if (!Result.Success) { + Message.textContent = + Result.Error ?? + "Couldn't change username."; + + return; + } + + Input.value = + Username; + + Message.textContent = + "Username saved."; + } catch (Error) { + console.error( + "Couldn't change username:", + Error + ); + + Message.textContent = + "Couldn't change username."; + } +} + + +function RenderProfileStatistics() { + const CurrentDew = + ProfileSave + ?.Currency + ?.Dew ?? + 0; + + const LifetimeDew = + ProfileSave + ?.Statistics + ?.CurrencyEarned + ?.Dew ?? + CurrentDew; + + const PlantsDiscovered = + ProfileSave + ?.Discoveries + ?.Plants + ?.length ?? + 0; + + const MutationsDiscovered = + ProfileSave + ?.Discoveries + ?.Mutations + ?.length ?? + 0; + + const GardenWidth = + ProfileSave + ?.Garden + ?.Width ?? + 0; + + const GardenHeight = + ProfileSave + ?.Garden + ?.Height ?? + 0; + + + SetProfileStatistic( + "ProfileCurrentDew", + FormatProfileNumber( + CurrentDew + ) + ); + + SetProfileStatistic( + "ProfileLifetimeDew", + FormatProfileNumber( + LifetimeDew + ) + ); + + SetProfileStatistic( + "ProfilePlantsDiscovered", + FormatProfileNumber( + PlantsDiscovered + ) + ); + + SetProfileStatistic( + "ProfileMutationsDiscovered", + FormatProfileNumber( + MutationsDiscovered + ) + ); + + SetProfileStatistic( + "ProfileGardenSize", + GardenWidth + + "×" + + GardenHeight + ); +} + + +function RenderProfileStatisticsError() { + const StatisticIds = [ + "ProfileCurrentDew", + "ProfileLifetimeDew", + "ProfilePlantsDiscovered", + "ProfileMutationsDiscovered", + "ProfileGardenSize" + ]; + + for ( + const StatisticId + of StatisticIds + ) { + SetProfileStatistic( + StatisticId, + "?" + ); + } +} + + +function SetProfileStatistic( + ElementId, + Value +) { + const Element = + document.getElementById( + ElementId + ); + + if (Element === null) { + return; + } + + Element.textContent = + Value; +} + + +function FormatProfileNumber( + Number +) { + return Number.toLocaleString(); +} + + +document.addEventListener( + "DOMContentLoaded", + StartProfile +); diff --git a/Scripts/Garden/Save.js b/Scripts/Garden/Save.js index 374d5ad..2ea285d 100644 --- a/Scripts/Garden/Save.js +++ b/Scripts/Garden/Save.js @@ -145,18 +145,21 @@ async function WriteRemoteSave(SaveData) { } -async function ReadRemoteSave() { +async function ReadRemoteSave( + SaveKey = GetSaveKey() +) { const Response = await fetch( ApiUrl + "/Load.php", { method: "POST", headers: { - "Content-Type": "application/json" + "Content-Type": + "application/json" }, body: JSON.stringify({ - SaveKey: GetSaveKey() + SaveKey: SaveKey }) } ); @@ -168,7 +171,8 @@ async function ReadRemoteSave() { ); } - const Result = await Response.json(); + const Result = + await Response.json(); if (!Result.Success) { throw new Error( @@ -184,7 +188,6 @@ async function ReadRemoteSave() { return Result.SaveData; } - async function SaveGame(SaveData) { SaveData.Revision++; SaveData.LastSavedAt = Date.now();