Added profile page to edit your profile and see stats.

This commit is contained in:
sarah committed 2026-08-30 18:14:06 +02:00
1 parent 39a8f18fa3
commit 11b26d972e
6 files changed
+1105 -6

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+2 -1
View File
@@ -3,7 +3,8 @@
$NavbarPages = [
"Home" => "/",
"Sarahtonin" => "/Pages/Sarahtonin.html",
"Garden" => "/Pages/Garden.html"
"Garden" => "/Pages/Garden.html",
"Profile" => "/Pages/Profile.html"
];
?>
+246
View File
@@ -0,0 +1,246 @@
<?php
$PageTitle = "Profile";
?>
<!DOCTYPE html>
<html lang="en" data-palette="CatppuccinMocha">
<?php require __DIR__ . "/Bits/Head.php"; ?>
<body>
<?php require __DIR__ . "/Bits/Navbar.php"; ?>
<main class="MainContainer">
<h1>Profile</h1>
<p>
Manage your Garden profile, view your
statistics, and move your account between
devices.
</p>
<section class="ProfileSection">
<h2>Player</h2>
<form id="ProfileUsernameForm">
<label
for="ProfileUsernameInput"
>
Username
</label>
<div class="ProfileFormRow">
<input
id="ProfileUsernameInput"
type="text"
minlength="3"
maxlength="24"
autocomplete="username"
placeholder="Username"
required
>
<button type="submit">
Save username
</button>
</div>
<p
id="ProfileUsernameMessage"
class="ProfileMessage"
></p>
</form>
</section>
<section class="ProfileSection">
<h2>Statistics</h2>
<div class="ProfileStatistics">
<div class="ProfileStatistic">
<span class="ProfileStatisticName">
Current Dew
</span>
<strong id="ProfileCurrentDew">
...
</strong>
</div>
<div class="ProfileStatistic">
<span class="ProfileStatisticName">
Lifetime Dew
</span>
<strong id="ProfileLifetimeDew">
...
</strong>
</div>
<div class="ProfileStatistic">
<span class="ProfileStatisticName">
Plants discovered
</span>
<strong id="ProfilePlantsDiscovered">
...
</strong>
</div>
<div class="ProfileStatistic">
<span class="ProfileStatisticName">
Mutations discovered
</span>
<strong id="ProfileMutationsDiscovered">
...
</strong>
</div>
<div class="ProfileStatistic">
<span class="ProfileStatisticName">
Garden size
</span>
<strong id="ProfileGardenSize">
...
</strong>
</div>
</div>
</section>
<section class="ProfileSection">
<h2>Account</h2>
<p>
Your Account Key is the secret key used
to identify your Garden account.
</p>
<p>
You can use the same Account Key on
another device to access the same
username, save, and leaderboard entry.
</p>
<p>
<strong>
Anyone with your Account Key can
access your Garden account.
</strong>
Keep it private and treat it like a
password.
</p>
<details class="AccountKeySpoiler">
<summary>
Show Account Key
</summary>
<div class="AccountKeyContent">
<p>
Do not share this key with
anyone you do not trust.
</p>
<code id="AccountKeyDisplay">
Loading...
</code>
<div class="ProfileButtonRow">
<button
id="CopyAccountKeyButton"
type="button"
>
Copy Account Key
</button>
<button
id="ExportAccountKeyButton"
type="button"
>
Export Account Key
</button>
</div>
<p
id="AccountKeyMessage"
class="ProfileMessage"
></p>
</div>
</details>
</section>
<section class="ProfileSection">
<h2>Import Account</h2>
<p>
To use an existing Garden account on
this device, paste its Account Key
below.
</p>
<p>
Importing an Account Key will replace
the account currently used by this
browser. It will not delete the old
account or its server save.
</p>
<form id="ImportAccountForm">
<label
for="ImportAccountKeyInput"
>
Account Key
</label>
<textarea
id="ImportAccountKeyInput"
rows="3"
spellcheck="false"
autocomplete="off"
placeholder="Paste Account Key here"
required
></textarea>
<div class="ProfileButtonRow">
<button type="submit">
Import Account
</button>
<button
id="ImportAccountKeyFileButton"
type="button"
>
Import From File
</button>
</div>
<input
id="ImportAccountKeyFile"
type="file"
accept=".txt,text/plain"
hidden
>
<p
id="ImportAccountMessage"
class="ProfileMessage"
></p>
</form>
</section>
</main>
<script src="/Scripts/Garden/Save.js"></script>
<script src="/Scripts/Garden/Users.js"></script>
<script src="/Scripts/Garden/Profile.js"></script>
<script src="/Scripts/Garden/Account.js"></script>
</body>
</html>
+581
View File
@@ -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
);
+268
View File
@@ -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
);
+8 -5
View File
@@ -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();