+ Back to Social +
+ +Gardener
+ ++ Loading gardener... +
++ Loading gardener... +
+ +From 56d70e8c0c3ec5ea57b3778bf0810cd549238f35 Mon Sep 17 00:00:00 2001
From: Sarahtonin
+ Back to Social
+
+ Loading gardener...
+
+ Loading gardener...
+
Your Profile
- contains your username, colour, Garden display settings,
- statistics, and Account Key. Garden saves can sync through
- the API, while the Account Key lets you use the same Garden
- in another browser or on another device. It's basically
- just like logging into an account.
+ contains your username, colour, Gardener description,
+ plant profile picture, privacy settings, Garden display
+ settings, statistics, and Account Key. Garden saves can
+ sync through the API, while the Account Key lets you use
+ the same Garden in another browser or on another device.
+ It's basically just like logging into an account.
@@ -325,6 +326,36 @@ $PageSection = "Guide";
+ The Social page lets
+ you search for other gardeners by username and open their
+ public Gardener page. Gardener profile pictures use a plant
+ and growth stage the player has discovered.
+
+ Gardener pages can show statistics and read-only previews
+ of every Garden the player owns. Players can independently
+ disable Garden previews, comments, Dew donations, and
+ public statistics from their Profile. Disabling public
+ statistics also hides the player from the Dew leaderboard.
+
+ Comments are simple messages left on a Gardener page. A
+ comment can be deleted by its author or by the owner of the
+ Gardener page. Dew donations transfer spendable Dew between
+ accounts, but donated Dew does not increase the recipient's
+ Lifetime Dew.
+ Gardener
+
+ Statistics
+
+ Gardens
+
+ Dew donation
+
+
+
+
+
+
+ Comments
+
+
+
+
+
+
+
+
+ Social and Gardener profiles
+
+
diff --git a/Pages/Profile.php b/Pages/Profile.php
index 28d0bf4..0402726 100644
--- a/Pages/Profile.php
+++ b/Pages/Profile.php
@@ -25,7 +25,7 @@ $PageTitle = "Profile";
Choose which parts of your Gardener profile
other players will be able to use or view.
+ Hiding statistics also removes you from the
+ public Dew leaderboard.
+ Find other gardeners, visit their public profiles,
+ preview their Gardens, leave comments, and donate Dew
+ when they allow it.
+
+ Search for a gardener by username.
+ Player
@@ -101,7 +101,7 @@ $PageTitle = "Profile";
Gardener profile
@@ -226,6 +226,8 @@ $PageTitle = "Profile";
Garden display
@@ -411,7 +413,7 @@ $PageTitle = "Profile";
Statistics
@@ -481,7 +483,7 @@ $PageTitle = "Profile";
Contact
@@ -501,7 +503,7 @@ $PageTitle = "Profile";
Account
@@ -610,7 +612,7 @@ $PageTitle = "Profile";
Import Account
diff --git a/Pages/Social.php b/Pages/Social.php
index 8e56b64..ef791bf 100644
--- a/Pages/Social.php
+++ b/Pages/Social.php
@@ -16,6 +16,54 @@ $PageSection = "Social";
Social
+ Find gardeners
+
+
+
+
+
+
- Username
+ Gardener
@@ -46,7 +94,11 @@ $PageSection = "Social";
+
+
+
+
diff --git a/Scripts/Garden/Gardener.js b/Scripts/Garden/Gardener.js
new file mode 100644
index 0000000..4942faa
--- /dev/null
+++ b/Scripts/Garden/Gardener.js
@@ -0,0 +1,1484 @@
+let GardenerData = null;
+let GardenerViewerProfile = null;
+let GardenerGardenIndex = 0;
+let GardenerCommentOffset = 0;
+const GardenerCommentPageSize = 20;
+
+
+async function StartGardener() {
+ const Query =
+ new URLSearchParams(
+ window.location.search
+ );
+
+ const Username =
+ Query.get("Username")?.trim() ?? "";
+
+ if (Username.length === 0) {
+ SetGardenerPageError(
+ "No gardener was selected."
+ );
+
+ return;
+ }
+
+ BindGardenerControls();
+
+ try {
+ await LoadGameContent();
+ } catch (Error) {
+ console.error(
+ "Couldn't load Garden content for gardener profile:",
+ Error
+ );
+ }
+
+ try {
+ const [Gardener, ViewerProfile] =
+ await Promise.all([
+ FetchGardener(
+ Username
+ ),
+ GetProfile().catch(
+ () => null
+ )
+ ]);
+
+ if (!Gardener.Success) {
+ throw new Error(
+ Gardener.Error ??
+ "Couldn't load gardener."
+ );
+ }
+
+ if (!Gardener.Exists) {
+ SetGardenerPageError(
+ "That gardener does not exist."
+ );
+
+ return;
+ }
+
+ GardenerData = Gardener;
+ GardenerViewerProfile =
+ ViewerProfile?.Success &&
+ ViewerProfile.Exists
+ ? ViewerProfile
+ : null;
+
+ GardenerGardenIndex =
+ Number.isInteger(
+ Number(
+ Gardener.ActiveGardenIndex
+ )
+ )
+ ? Number(
+ Gardener.ActiveGardenIndex
+ )
+ : 0;
+
+ RenderGardener();
+
+ if (
+ Gardener.Permissions?.Comments !==
+ false
+ ) {
+ await LoadGardenerComments(
+ true
+ );
+ }
+ } catch (Error) {
+ console.error(
+ "Couldn't load gardener:",
+ Error
+ );
+
+ SetGardenerPageError(
+ "Couldn't load gardener."
+ );
+ }
+}
+
+
+function BindGardenerControls() {
+ document.getElementById(
+ "PreviousGardenerGardenButton"
+ )?.addEventListener(
+ "click",
+ () => ChangeGardenerGarden(-1)
+ );
+
+ document.getElementById(
+ "NextGardenerGardenButton"
+ )?.addEventListener(
+ "click",
+ () => ChangeGardenerGarden(1)
+ );
+
+ document.getElementById(
+ "GardenerDonationForm"
+ )?.addEventListener(
+ "submit",
+ SubmitGardenerDonation
+ );
+
+ document.getElementById(
+ "GardenerCommentForm"
+ )?.addEventListener(
+ "submit",
+ SubmitGardenerComment
+ );
+
+ document.getElementById(
+ "LoadMoreGardenerCommentsButton"
+ )?.addEventListener(
+ "click",
+ () => LoadGardenerComments(false)
+ );
+}
+
+
+async function FetchGardener(
+ Username
+) {
+ const Response = await fetch(
+ ApiUrl + "/Gardener.php",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+ body: JSON.stringify({
+ SaveKey: GetSaveKey(),
+ Username: Username
+ })
+ }
+ );
+
+ return await Response.json();
+}
+
+
+function RenderGardener() {
+ RenderGardenerHeader();
+ RenderGardenerStatistics();
+ RenderGardenerGardens();
+ RenderGardenerDonation();
+ RenderGardenerCommentsAvailability();
+
+ const Message =
+ document.getElementById(
+ "GardenerPageMessage"
+ );
+
+ if (Message !== null) {
+ Message.hidden = true;
+ }
+}
+
+
+function RenderGardenerHeader() {
+ const AvatarContainer =
+ document.getElementById(
+ "GardenerAvatar"
+ );
+
+ const Name =
+ document.getElementById(
+ "GardenerName"
+ );
+
+ const Description =
+ document.getElementById(
+ "GardenerDescription"
+ );
+
+ if (
+ AvatarContainer === null ||
+ Name === null ||
+ Description === null ||
+ GardenerData === null
+ ) {
+ return;
+ }
+
+ AvatarContainer.replaceChildren(
+ CreateGardenerAvatar(
+ GardenerData.ProfilePicture,
+ GardenerData.Username,
+ "GardenerAvatarHero"
+ )
+ );
+
+ Name.textContent =
+ GardenerData.Username;
+
+ ApplyPlayerColour(
+ Name,
+ GardenerData.Colour
+ );
+
+ Description.textContent =
+ typeof GardenerData.Description ===
+ "string" &&
+ GardenerData.Description.length > 0
+ ? GardenerData.Description
+ : "No description.";
+}
+
+
+function RenderGardenerStatistics() {
+ const Section =
+ document.getElementById(
+ "GardenerStatsSection"
+ );
+
+ if (
+ Section === null ||
+ GardenerData === null
+ ) {
+ return;
+ }
+
+ Section.hidden = false;
+
+ const Stats = GardenerData.Stats;
+
+ if (
+ Stats === null ||
+ typeof Stats !== "object"
+ ) {
+ Section.replaceChildren(
+ CreateGardenerPrivateSection(
+ "Statistics",
+ "This gardener keeps their statistics private."
+ )
+ );
+
+ return;
+ }
+
+ SetGardenerStatistic(
+ "GardenerCurrentDew",
+ Stats.CurrentDew
+ );
+
+ SetGardenerStatistic(
+ "GardenerLifetimeDew",
+ Stats.LifetimeDew
+ );
+
+ SetGardenerStatistic(
+ "GardenerPlantsDiscovered",
+ Stats.PlantsDiscovered
+ );
+
+ SetGardenerStatistic(
+ "GardenerMutationsDiscovered",
+ Stats.MutationsDiscovered
+ );
+
+ SetGardenerStatistic(
+ "GardenerGardensOwned",
+ Stats.GardensOwned
+ );
+
+ const GardenSize =
+ Number(Stats.ActiveGardenWidth) +
+ "×" +
+ Number(Stats.ActiveGardenHeight);
+
+ const GardenSizeElement =
+ document.getElementById(
+ "GardenerActiveGardenSize"
+ );
+
+ if (GardenSizeElement !== null) {
+ GardenSizeElement.textContent =
+ GardenSize;
+ }
+}
+
+
+function SetGardenerStatistic(
+ ElementId,
+ Value
+) {
+ const Element =
+ document.getElementById(
+ ElementId
+ );
+
+ if (Element !== null) {
+ Element.textContent =
+ Number(Value ?? 0)
+ .toLocaleString();
+ }
+}
+
+
+function CreateGardenerPrivateSection(
+ Title,
+ Message
+) {
+ const Fragment =
+ document.createDocumentFragment();
+
+ const Heading =
+ document.createElement(
+ "h2"
+ );
+
+ Heading.className =
+ "PanelHeader PanelHeaderInset";
+
+ Heading.textContent = Title;
+
+ const Text =
+ document.createElement(
+ "p"
+ );
+
+ Text.className =
+ "ProfileMessage";
+
+ Text.textContent = Message;
+
+ Fragment.append(
+ Heading,
+ Text
+ );
+
+ return Fragment;
+}
+
+
+function RenderGardenerGardens() {
+ const Section =
+ document.getElementById(
+ "GardenerGardensSection"
+ );
+
+ if (
+ Section === null ||
+ GardenerData === null
+ ) {
+ return;
+ }
+
+ Section.hidden = false;
+
+ if (!Array.isArray(GardenerData.Gardens)) {
+ Section.replaceChildren(
+ CreateGardenerPrivateSection(
+ "Gardens",
+ "This gardener has disabled Garden previews."
+ )
+ );
+
+ return;
+ }
+
+ if (GardenerData.Gardens.length === 0) {
+ const Message =
+ document.getElementById(
+ "GardenerGardensMessage"
+ );
+
+ if (Message !== null) {
+ Message.textContent =
+ "This gardener has no Gardens to preview.";
+ }
+
+ document.getElementById(
+ "GardenerGardenGrid"
+ )?.replaceChildren();
+
+ return;
+ }
+
+ GardenerGardenIndex = Math.max(
+ 0,
+ Math.min(
+ GardenerData.Gardens.length - 1,
+ GardenerGardenIndex
+ )
+ );
+
+ RenderCurrentGardenerGarden();
+}
+
+
+function ChangeGardenerGarden(
+ Difference
+) {
+ if (
+ !Array.isArray(
+ GardenerData?.Gardens
+ ) ||
+ GardenerData.Gardens.length <= 1
+ ) {
+ return;
+ }
+
+ GardenerGardenIndex =
+ (
+ GardenerGardenIndex +
+ Difference +
+ GardenerData.Gardens.length
+ ) % GardenerData.Gardens.length;
+
+ RenderCurrentGardenerGarden();
+}
+
+
+function RenderCurrentGardenerGarden() {
+ const Gardens =
+ GardenerData?.Gardens;
+
+ if (
+ !Array.isArray(Gardens) ||
+ Gardens.length === 0
+ ) {
+ return;
+ }
+
+ const Garden =
+ Gardens[GardenerGardenIndex];
+
+ const Grid =
+ document.getElementById(
+ "GardenerGardenGrid"
+ );
+
+ const Name =
+ document.getElementById(
+ "GardenerGardenName"
+ );
+
+ const Size =
+ document.getElementById(
+ "GardenerGardenSize"
+ );
+
+ const Position =
+ document.getElementById(
+ "GardenerGardenPosition"
+ );
+
+ const PreviousButton =
+ document.getElementById(
+ "PreviousGardenerGardenButton"
+ );
+
+ const NextButton =
+ document.getElementById(
+ "NextGardenerGardenButton"
+ );
+
+ const Message =
+ document.getElementById(
+ "GardenerGardensMessage"
+ );
+
+ if (Grid === null) {
+ return;
+ }
+
+ Grid.style.setProperty(
+ "--GardenWidth",
+ Math.max(
+ 1,
+ Number(Garden.Width) || 1
+ )
+ );
+
+ Grid.replaceChildren();
+
+ for (
+ const Plot
+ of Array.isArray(Garden.Plots)
+ ? Garden.Plots
+ : []
+ ) {
+ Grid.appendChild(
+ CreateGardenerGardenPlot(
+ Plot
+ )
+ );
+ }
+
+ if (Name !== null) {
+ Name.textContent =
+ Garden.Name ?? "Garden";
+ }
+
+ if (Size !== null) {
+ Size.textContent =
+ Number(Garden.Width) +
+ "×" +
+ Number(Garden.Height);
+ }
+
+ if (Position !== null) {
+ Position.textContent =
+ (GardenerGardenIndex + 1) +
+ " / " +
+ Gardens.length;
+ }
+
+ const HasMultiple =
+ Gardens.length > 1;
+
+ if (PreviousButton !== null) {
+ PreviousButton.disabled =
+ !HasMultiple;
+ }
+
+ if (NextButton !== null) {
+ NextButton.disabled =
+ !HasMultiple;
+ }
+
+ if (Message !== null) {
+ Message.textContent =
+ "Read-only Garden preview.";
+ }
+}
+
+
+function CreateGardenerGardenPlot(
+ Plot
+) {
+ const Tile =
+ document.createElement(
+ "div"
+ );
+
+ Tile.className =
+ "PlantTile GardenPlot GardenPlotReadOnly";
+
+ const Main =
+ document.createElement(
+ "button"
+ );
+
+ Main.type = "button";
+ Main.className =
+ "GardenPlotMain";
+ Main.disabled = true;
+
+ const Visual =
+ document.createElement(
+ "span"
+ );
+
+ Visual.className =
+ "GardenPlotVisual";
+
+ if (
+ Plot === null ||
+ typeof Plot !== "object"
+ ) {
+ Tile.classList.add(
+ "GardenPlotEmpty"
+ );
+
+ Main.title = "Empty plot";
+ Main.appendChild(
+ Visual
+ );
+ Tile.appendChild(
+ Main
+ );
+
+ return Tile;
+ }
+
+ const Plant =
+ Plants[Plot.Plant];
+
+ Tile.classList.add(
+ "GardenPlotWithName"
+ );
+
+ const PlantName =
+ document.createElement(
+ "span"
+ );
+
+ PlantName.className =
+ "GardenPlotName";
+
+ PlantName.textContent =
+ Plant?.Name ?? Plot.Plant ?? "Unknown";
+
+ Main.appendChild(
+ PlantName
+ );
+
+ if (Plant === undefined) {
+ Visual.textContent = "?";
+ Main.title = "Unknown plant";
+ Main.appendChild(
+ Visual
+ );
+ Tile.appendChild(
+ Main
+ );
+
+ return Tile;
+ }
+
+ const Progress =
+ GetGardenerGardenPlantProgress(
+ Plot,
+ Plant
+ );
+
+ Tile.style.setProperty(
+ "--GrowthProgress",
+ (Progress * 100) + "%"
+ );
+
+ if (Progress >= 1) {
+ Tile.classList.add(
+ "GardenPlotMature"
+ );
+ }
+
+ const ImageSource =
+ GetGardenerGardenPlantImage(
+ Plot,
+ Plant,
+ Progress
+ );
+
+ if (ImageSource !== null) {
+ const Image =
+ document.createElement(
+ "img"
+ );
+
+ Image.className =
+ "PlantSprite";
+ Image.src = ImageSource;
+ Image.alt = "";
+ Image.draggable = false;
+
+ Visual.appendChild(
+ Image
+ );
+ }
+
+ Main.title =
+ Plant.Name +
+ (Progress >= 1
+ ? " — mature"
+ : " — growing");
+
+ Main.appendChild(
+ Visual
+ );
+
+ Tile.appendChild(
+ Main
+ );
+
+ return Tile;
+}
+
+
+function GetGardenerGardenPlantProgress(
+ Plot,
+ Plant
+) {
+ const GrowthTime =
+ Number(Plant.GrowthTime ?? 0);
+
+ if (GrowthTime <= 0) {
+ return 1;
+ }
+
+ const PlantedAt =
+ Number(Plot.PlantedAt ?? 0);
+
+ const Age =
+ Date.now() - PlantedAt;
+
+ return Math.max(
+ 0,
+ Math.min(
+ Age / GrowthTime,
+ 1
+ )
+ );
+}
+
+
+function GetGardenerGardenPlantImage(
+ Plot,
+ Plant,
+ Progress
+) {
+ const Direction =
+ Plant.DirectionalSprites === true
+ ? Plot.Rotation ?? "East"
+ : null;
+
+ const Images =
+ GetPlantImageSources(
+ Plant,
+ Direction
+ );
+
+ if (Images.length === 0) {
+ return null;
+ }
+
+ if (Images.length === 1) {
+ return Images[0] ?? null;
+ }
+
+ if (Progress >= 1) {
+ return Images[
+ Images.length - 1
+ ] ?? null;
+ }
+
+ const GrowingImageCount =
+ Images.length - 1;
+
+ const Index = Math.min(
+ Math.floor(
+ Progress *
+ GrowingImageCount
+ ),
+ GrowingImageCount - 1
+ );
+
+ return Images[Index] ?? null;
+}
+
+
+function RenderGardenerDonation() {
+ const Section =
+ document.getElementById(
+ "GardenerDonationSection"
+ );
+
+ const Form =
+ document.getElementById(
+ "GardenerDonationForm"
+ );
+
+ const Message =
+ document.getElementById(
+ "GardenerDonationMessage"
+ );
+
+ if (
+ Section === null ||
+ Form === null ||
+ Message === null ||
+ GardenerData === null
+ ) {
+ return;
+ }
+
+ Section.hidden = false;
+
+ if (
+ GardenerData.Permissions
+ ?.DewDonations === false
+ ) {
+ Form.hidden = true;
+ SetGardenerDonationBalance(null);
+ Message.textContent =
+ "This gardener has disabled Dew donations.";
+ return;
+ }
+
+ if (GardenerData.IsSelf === true) {
+ Form.hidden = true;
+ SetGardenerDonationBalance(
+ GardenerViewerProfile?.CurrentDew ?? null
+ );
+ Message.textContent =
+ "You cannot donate Dew to yourself.";
+ return;
+ }
+
+ if (GardenerViewerProfile === null) {
+ Form.hidden = true;
+ SetGardenerDonationBalance(null);
+ Message.textContent =
+ "Create a Garden save before donating Dew.";
+ return;
+ }
+
+ Form.hidden = false;
+ Message.textContent = "";
+
+ SetGardenerDonationBalance(
+ GardenerViewerProfile.CurrentDew
+ );
+}
+
+
+function SetGardenerDonationBalance(
+ Dew
+) {
+ const Balance =
+ document.getElementById(
+ "GardenerDonationBalance"
+ );
+
+ if (Balance === null) {
+ return;
+ }
+
+ if (!Number.isFinite(Number(Dew))) {
+ Balance.textContent = "";
+ return;
+ }
+
+ Balance.textContent =
+ "You have " +
+ Number(Dew).toLocaleString() +
+ " Dew available.";
+}
+
+
+async function SubmitGardenerDonation(
+ Event
+) {
+ Event.preventDefault();
+
+ if (GardenerData === null) {
+ return;
+ }
+
+ const Input =
+ document.getElementById(
+ "GardenerDonationAmount"
+ );
+
+ const Message =
+ document.getElementById(
+ "GardenerDonationMessage"
+ );
+
+ const Button =
+ Event.currentTarget.querySelector(
+ "button[type='submit']"
+ );
+
+ const Amount = Number(
+ Input?.value
+ );
+
+ if (
+ !Number.isInteger(Amount) ||
+ Amount < 1
+ ) {
+ if (Message !== null) {
+ Message.textContent =
+ "Enter a positive whole number of Dew.";
+ }
+
+ return;
+ }
+
+ if (Button !== null) {
+ Button.disabled = true;
+ }
+
+ if (Message !== null) {
+ Message.textContent =
+ "Donating...";
+ }
+
+ try {
+ const Response = await fetch(
+ ApiUrl + "/DonateDew.php",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+ body: JSON.stringify({
+ SaveKey: GetSaveKey(),
+ Username:
+ GardenerData.Username,
+ Amount: Amount
+ })
+ }
+ );
+
+ const Result =
+ await Response.json();
+
+ if (!Result.Success) {
+ throw new Error(
+ Result.Error ??
+ "Couldn't donate Dew."
+ );
+ }
+
+ if (
+ Result.SaveData !== null &&
+ typeof Result.SaveData ===
+ "object"
+ ) {
+ WriteLocalSave(
+ NormalizeSaveData(
+ Result.SaveData
+ )
+ );
+ }
+
+ if (GardenerViewerProfile !== null) {
+ GardenerViewerProfile.CurrentDew =
+ Number(Result.CurrentDew);
+ }
+
+ if (
+ GardenerData.Stats !== null &&
+ typeof GardenerData.Stats ===
+ "object" &&
+ Number.isFinite(
+ Number(Result.TargetCurrentDew)
+ )
+ ) {
+ GardenerData.Stats.CurrentDew =
+ Number(
+ Result.TargetCurrentDew
+ );
+
+ RenderGardenerStatistics();
+ }
+
+ SetGardenerDonationBalance(
+ Result.CurrentDew
+ );
+
+ if (Input !== null) {
+ Input.value = "";
+ }
+
+ if (Message !== null) {
+ Message.textContent =
+ "Donated " +
+ Amount.toLocaleString() +
+ " Dew to " +
+ GardenerData.Username +
+ ".";
+ }
+ } catch (Error) {
+ console.error(
+ "Couldn't donate Dew:",
+ Error
+ );
+
+ if (Message !== null) {
+ Message.textContent =
+ Error.message ??
+ "Couldn't donate Dew.";
+ }
+ } finally {
+ if (Button !== null) {
+ Button.disabled = false;
+ }
+ }
+}
+
+
+function RenderGardenerCommentsAvailability() {
+ const Section =
+ document.getElementById(
+ "GardenerCommentsSection"
+ );
+
+ const Form =
+ document.getElementById(
+ "GardenerCommentForm"
+ );
+
+ const Message =
+ document.getElementById(
+ "GardenerCommentMessage"
+ );
+
+ if (
+ Section === null ||
+ Form === null ||
+ Message === null ||
+ GardenerData === null
+ ) {
+ return;
+ }
+
+ Section.hidden = false;
+
+ if (
+ GardenerData.Permissions
+ ?.Comments === false
+ ) {
+ Form.hidden = true;
+ Message.textContent =
+ "This gardener has disabled comments.";
+ document.getElementById(
+ "GardenerCommentList"
+ )?.replaceChildren();
+ return;
+ }
+
+ if (
+ GardenerViewerProfile === null ||
+ typeof GardenerViewerProfile.Username !==
+ "string" ||
+ GardenerViewerProfile.Username.length === 0
+ ) {
+ Form.hidden = true;
+ Message.textContent =
+ "Set a username before leaving comments.";
+ return;
+ }
+
+ Form.hidden = false;
+ Message.textContent = "";
+}
+
+
+async function LoadGardenerComments(
+ Reset
+) {
+ if (GardenerData === null) {
+ return;
+ }
+
+ const List =
+ document.getElementById(
+ "GardenerCommentList"
+ );
+
+ const LoadMoreButton =
+ document.getElementById(
+ "LoadMoreGardenerCommentsButton"
+ );
+
+ if (List === null) {
+ return;
+ }
+
+ if (Reset) {
+ GardenerCommentOffset = 0;
+ List.replaceChildren();
+ }
+
+ if (LoadMoreButton !== null) {
+ LoadMoreButton.disabled = true;
+ }
+
+ try {
+ const Response = await fetch(
+ ApiUrl + "/Comments.php",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+ body: JSON.stringify({
+ Action: "List",
+ SaveKey: GetSaveKey(),
+ Username:
+ GardenerData.Username,
+ Offset:
+ GardenerCommentOffset,
+ Limit:
+ GardenerCommentPageSize
+ })
+ }
+ );
+
+ const Result =
+ await Response.json();
+
+ if (!Result.Success) {
+ throw new Error(
+ Result.Error ??
+ "Couldn't load comments."
+ );
+ }
+
+ if (!Result.Enabled) {
+ RenderGardenerCommentsAvailability();
+ return;
+ }
+
+ const Comments =
+ Array.isArray(Result.Comments)
+ ? Result.Comments
+ : [];
+
+ for (const Comment of Comments) {
+ List.appendChild(
+ CreateGardenerComment(
+ Comment
+ )
+ );
+ }
+
+ GardenerCommentOffset +=
+ Comments.length;
+
+ if (
+ Reset &&
+ Comments.length === 0
+ ) {
+ const Empty =
+ document.createElement(
+ "p"
+ );
+
+ Empty.className =
+ "GardenerCommentsEmpty";
+
+ Empty.textContent =
+ "No comments yet.";
+
+ List.appendChild(
+ Empty
+ );
+ }
+
+ if (LoadMoreButton !== null) {
+ LoadMoreButton.hidden =
+ Result.HasMore !== true;
+ }
+ } catch (Error) {
+ console.error(
+ "Couldn't load gardener comments:",
+ Error
+ );
+
+ const Message =
+ document.getElementById(
+ "GardenerCommentMessage"
+ );
+
+ if (Message !== null) {
+ Message.textContent =
+ "Couldn't load comments.";
+ }
+ } finally {
+ if (LoadMoreButton !== null) {
+ LoadMoreButton.disabled = false;
+ }
+ }
+}
+
+
+function CreateGardenerComment(
+ Comment
+) {
+ const Article =
+ document.createElement(
+ "article"
+ );
+
+ Article.className =
+ "PlantTile GardenerComment";
+
+ Article.dataset.commentId =
+ String(Comment.Id);
+
+ const Header =
+ document.createElement(
+ "div"
+ );
+
+ Header.className =
+ "GardenerCommentHeader";
+
+ const Identity =
+ CreateGardenerIdentity(
+ Comment.Username,
+ Comment.Colour,
+ Comment.ProfilePicture
+ );
+
+ const Time =
+ document.createElement(
+ "time"
+ );
+
+ Time.className =
+ "GardenerCommentTime";
+
+ const CreatedAt = Number(
+ Comment.CreatedAt
+ );
+
+ if (Number.isFinite(CreatedAt)) {
+ const DateValue =
+ new Date(CreatedAt);
+
+ Time.dateTime =
+ DateValue.toISOString();
+
+ Time.textContent =
+ DateValue.toLocaleString();
+ }
+
+ Header.append(
+ Identity,
+ Time
+ );
+
+ const Text =
+ document.createElement(
+ "p"
+ );
+
+ Text.className =
+ "GardenerCommentText";
+
+ Text.textContent =
+ Comment.Text ?? "";
+
+ Article.append(
+ Header,
+ Text
+ );
+
+ if (Comment.CanDelete === true) {
+ const DeleteButton =
+ document.createElement(
+ "button"
+ );
+
+ DeleteButton.type = "button";
+ DeleteButton.className =
+ "ActionButton GardenerCommentDelete";
+ DeleteButton.textContent =
+ "Delete";
+
+ DeleteButton.addEventListener(
+ "click",
+ () => DeleteGardenerComment(
+ Number(Comment.Id),
+ DeleteButton
+ )
+ );
+
+ Article.appendChild(
+ DeleteButton
+ );
+ }
+
+ return Article;
+}
+
+
+async function SubmitGardenerComment(
+ Event
+) {
+ Event.preventDefault();
+
+ if (GardenerData === null) {
+ return;
+ }
+
+ const Input =
+ document.getElementById(
+ "GardenerCommentInput"
+ );
+
+ const Message =
+ document.getElementById(
+ "GardenerCommentMessage"
+ );
+
+ const Button =
+ Event.currentTarget.querySelector(
+ "button[type='submit']"
+ );
+
+ const Text =
+ Input?.value.trim() ?? "";
+
+ if (Text.length === 0) {
+ if (Message !== null) {
+ Message.textContent =
+ "Comment cannot be empty.";
+ }
+
+ return;
+ }
+
+ if (Button !== null) {
+ Button.disabled = true;
+ }
+
+ if (Message !== null) {
+ Message.textContent =
+ "Posting...";
+ }
+
+ try {
+ const Response = await fetch(
+ ApiUrl + "/Comments.php",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+ body: JSON.stringify({
+ Action: "Add",
+ SaveKey: GetSaveKey(),
+ Username:
+ GardenerData.Username,
+ Text: Text
+ })
+ }
+ );
+
+ const Result =
+ await Response.json();
+
+ if (!Result.Success) {
+ throw new Error(
+ Result.Error ??
+ "Couldn't leave comment."
+ );
+ }
+
+ if (Input !== null) {
+ Input.value = "";
+ }
+
+ if (Message !== null) {
+ Message.textContent =
+ "Comment posted.";
+ }
+
+ await LoadGardenerComments(
+ true
+ );
+ } catch (Error) {
+ console.error(
+ "Couldn't leave comment:",
+ Error
+ );
+
+ if (Message !== null) {
+ Message.textContent =
+ Error.message ??
+ "Couldn't leave comment.";
+ }
+ } finally {
+ if (Button !== null) {
+ Button.disabled = false;
+ }
+ }
+}
+
+
+async function DeleteGardenerComment(
+ CommentId,
+ Button
+) {
+ Button.disabled = true;
+
+ const Message =
+ document.getElementById(
+ "GardenerCommentMessage"
+ );
+
+ try {
+ const Response = await fetch(
+ ApiUrl + "/Comments.php",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+ body: JSON.stringify({
+ Action: "Delete",
+ SaveKey: GetSaveKey(),
+ CommentId: CommentId
+ })
+ }
+ );
+
+ const Result =
+ await Response.json();
+
+ if (!Result.Success) {
+ throw new Error(
+ Result.Error ??
+ "Couldn't delete comment."
+ );
+ }
+
+ if (Message !== null) {
+ Message.textContent =
+ "Comment deleted.";
+ }
+
+ await LoadGardenerComments(
+ true
+ );
+ } catch (Error) {
+ console.error(
+ "Couldn't delete comment:",
+ Error
+ );
+
+ if (Message !== null) {
+ Message.textContent =
+ Error.message ??
+ "Couldn't delete comment.";
+ }
+
+ Button.disabled = false;
+ }
+}
+
+
+function SetGardenerPageError(
+ Message
+) {
+ const PageMessage =
+ document.getElementById(
+ "GardenerPageMessage"
+ );
+
+ if (PageMessage !== null) {
+ PageMessage.hidden = false;
+ PageMessage.textContent =
+ Message;
+ }
+
+ const Description =
+ document.getElementById(
+ "GardenerDescription"
+ );
+
+ if (Description !== null) {
+ Description.textContent = "";
+ }
+}
+
+
+document.addEventListener(
+ "DOMContentLoaded",
+ StartGardener
+);
diff --git a/Scripts/Garden/Leaderboard.js b/Scripts/Garden/Leaderboard.js
index 4808c59..1b5d19e 100644
--- a/Scripts/Garden/Leaderboard.js
+++ b/Scripts/Garden/Leaderboard.js
@@ -147,12 +147,12 @@ async function RenderLeaderboard(
Rank.textContent =
Player.Rank + ".";
- Username.textContent =
- Player.Username;
-
- ApplyPlayerColour(
- Username,
- Player.Colour
+ Username.appendChild(
+ CreateGardenerIdentity(
+ Player.Username,
+ Player.Colour,
+ Player.ProfilePicture
+ )
);
Dew.textContent =
diff --git a/Scripts/Garden/Social.js b/Scripts/Garden/Social.js
index e5bb34a..28a8149 100644
--- a/Scripts/Garden/Social.js
+++ b/Scripts/Garden/Social.js
@@ -1,5 +1,17 @@
+let SocialCurrentUsername = null;
+let GardenerSearchRequest = 0;
+let GardenerSearchTimer = null;
+
+
async function StartSocial() {
- let CurrentUsername = null;
+ try {
+ await LoadGameContent();
+ } catch (Error) {
+ console.error(
+ "Couldn't load Garden content for Social:",
+ Error
+ );
+ }
try {
const Profile = await GetProfile();
@@ -8,7 +20,7 @@ async function StartSocial() {
Profile.Success &&
Profile.Username !== null
) {
- CurrentUsername =
+ SocialCurrentUsername =
Profile.Username;
}
} catch (Error) {
@@ -18,12 +30,309 @@ async function StartSocial() {
);
}
+ BindGardenerSearch();
+
await RenderLeaderboard(
- CurrentUsername
+ SocialCurrentUsername
);
}
+function BindGardenerSearch() {
+ const Form =
+ document.getElementById(
+ "GardenerSearchForm"
+ );
+
+ const Input =
+ document.getElementById(
+ "GardenerSearchInput"
+ );
+
+ if (
+ Form === null ||
+ Input === null
+ ) {
+ return;
+ }
+
+ Form.addEventListener(
+ "submit",
+ Event => {
+ Event.preventDefault();
+
+ SearchGardeners(
+ Input.value
+ );
+ }
+ );
+
+ Input.addEventListener(
+ "input",
+ () => {
+ if (GardenerSearchTimer !== null) {
+ clearTimeout(
+ GardenerSearchTimer
+ );
+ }
+
+ const Search =
+ Input.value.trim();
+
+ if (Search.length === 0) {
+ ClearGardenerSearch();
+ return;
+ }
+
+ GardenerSearchTimer =
+ setTimeout(
+ () => {
+ SearchGardeners(
+ Search
+ );
+ },
+ 250
+ );
+ }
+ );
+
+ const Query =
+ new URLSearchParams(
+ window.location.search
+ );
+
+ const InitialSearch =
+ Query.get("Search")?.trim() ?? "";
+
+ if (InitialSearch.length > 0) {
+ Input.value = InitialSearch;
+ SearchGardeners(
+ InitialSearch
+ );
+ }
+}
+
+
+function ClearGardenerSearch() {
+ GardenerSearchRequest++;
+
+ const Results =
+ document.getElementById(
+ "GardenerSearchResults"
+ );
+
+ Results?.replaceChildren();
+
+ SetGardenerSearchMessage(
+ "Search for a gardener by username."
+ );
+}
+
+
+async function SearchGardeners(
+ SearchValue
+) {
+ const Search =
+ String(SearchValue ?? "")
+ .trim();
+
+ if (Search.length === 0) {
+ ClearGardenerSearch();
+ return;
+ }
+
+ const RequestId =
+ ++GardenerSearchRequest;
+
+ SetGardenerSearchMessage(
+ "Searching..."
+ );
+
+ try {
+ const Query =
+ new URLSearchParams({
+ Search: Search
+ });
+
+ const Response = await fetch(
+ ApiUrl +
+ "/Gardeners.php?" +
+ Query.toString(),
+ {
+ cache: "no-store"
+ }
+ );
+
+ const Result =
+ await Response.json();
+
+ if (RequestId !== GardenerSearchRequest) {
+ return;
+ }
+
+ if (!Result.Success) {
+ throw new Error(
+ Result.Error ??
+ "Couldn't search gardeners."
+ );
+ }
+
+ RenderGardenerSearchResults(
+ Array.isArray(Result.Gardeners)
+ ? Result.Gardeners
+ : []
+ );
+ } catch (Error) {
+ if (RequestId !== GardenerSearchRequest) {
+ return;
+ }
+
+ console.error(
+ "Couldn't search gardeners:",
+ Error
+ );
+
+ document.getElementById(
+ "GardenerSearchResults"
+ )?.replaceChildren();
+
+ SetGardenerSearchMessage(
+ "Couldn't search gardeners."
+ );
+ }
+}
+
+
+function RenderGardenerSearchResults(
+ Gardeners
+) {
+ const Results =
+ document.getElementById(
+ "GardenerSearchResults"
+ );
+
+ if (Results === null) {
+ return;
+ }
+
+ Results.replaceChildren();
+
+ if (Gardeners.length === 0) {
+ SetGardenerSearchMessage(
+ "No gardeners matched that search."
+ );
+
+ return;
+ }
+
+ for (const Gardener of Gardeners) {
+ Results.appendChild(
+ CreateGardenerSearchResult(
+ Gardener
+ )
+ );
+ }
+
+ SetGardenerSearchMessage(
+ Gardeners.length === 1
+ ? "1 gardener found."
+ : Gardeners.length
+ .toLocaleString() +
+ " gardeners found."
+ );
+}
+
+
+function CreateGardenerSearchResult(
+ Gardener
+) {
+ const Link =
+ document.createElement(
+ "a"
+ );
+
+ Link.className =
+ "PlantTile GardenerSearchResult";
+
+ Link.href =
+ GetGardenerPageUrl(
+ Gardener.Username
+ );
+
+ const Avatar =
+ CreateGardenerAvatar(
+ Gardener.ProfilePicture,
+ Gardener.Username,
+ "GardenerAvatarSearch"
+ );
+
+ const Details =
+ document.createElement(
+ "span"
+ );
+
+ Details.className =
+ "GardenerSearchResultDetails";
+
+ const Name =
+ document.createElement(
+ "strong"
+ );
+
+ Name.className =
+ "GardenerSearchResultName";
+
+ Name.textContent =
+ Gardener.Username;
+
+ ApplyPlayerColour(
+ Name,
+ Gardener.Colour
+ );
+
+ const Description =
+ document.createElement(
+ "span"
+ );
+
+ Description.className =
+ "GardenerSearchResultDescription";
+
+ Description.textContent =
+ typeof Gardener.Description ===
+ "string" &&
+ Gardener.Description.length > 0
+ ? Gardener.Description
+ : "No description.";
+
+ Details.append(
+ Name,
+ Description
+ );
+
+ Link.append(
+ Avatar,
+ Details
+ );
+
+ return Link;
+}
+
+
+function SetGardenerSearchMessage(
+ Text
+) {
+ const Message =
+ document.getElementById(
+ "GardenerSearchMessage"
+ );
+
+ if (Message !== null) {
+ Message.textContent = Text;
+ }
+}
+
+
document.addEventListener(
"DOMContentLoaded",
StartSocial
diff --git a/Scripts/Garden/Users.js b/Scripts/Garden/Users.js
index a4572d0..69a31d8 100644
--- a/Scripts/Garden/Users.js
+++ b/Scripts/Garden/Users.js
@@ -191,6 +191,219 @@ function ApplyPlayerColour(
}
+function GetGardenerProfilePictureSource(
+ ProfilePicture
+) {
+ if (
+ ProfilePicture === null ||
+ typeof ProfilePicture !== "object" ||
+ Array.isArray(ProfilePicture) ||
+ typeof ProfilePicture.PlantKey !==
+ "string" ||
+ !Number.isInteger(
+ Number(
+ ProfilePicture.GrowthStage
+ )
+ )
+ ) {
+ return null;
+ }
+
+ if (
+ typeof Plants === "undefined" ||
+ typeof GetPlantImageSources !==
+ "function"
+ ) {
+ return null;
+ }
+
+ const Plant =
+ Plants[
+ ProfilePicture.PlantKey
+ ];
+
+ if (Plant === undefined) {
+ return null;
+ }
+
+ const Images =
+ GetPlantImageSources(
+ Plant
+ );
+
+ const GrowthStage = Number(
+ ProfilePicture.GrowthStage
+ );
+
+ const RequestedSource =
+ Images[GrowthStage - 1];
+
+ if (
+ typeof RequestedSource ===
+ "string" &&
+ RequestedSource.length > 0
+ ) {
+ return RequestedSource;
+ }
+
+ for (
+ let Index = Images.length - 1;
+ Index >= 0;
+ Index--
+ ) {
+ if (
+ typeof Images[Index] ===
+ "string" &&
+ Images[Index].length > 0
+ ) {
+ return Images[Index];
+ }
+ }
+
+ return null;
+}
+
+
+function CreateGardenerAvatar(
+ ProfilePicture,
+ Username = "",
+ AdditionalClass = null
+) {
+ const Avatar =
+ document.createElement(
+ "span"
+ );
+
+ Avatar.className =
+ "PlantTile GardenerAvatar";
+
+ if (
+ typeof AdditionalClass ===
+ "string" &&
+ AdditionalClass.length > 0
+ ) {
+ Avatar.classList.add(
+ AdditionalClass
+ );
+ }
+
+ if (Username.length > 0) {
+ Avatar.title =
+ Username +
+ "'s profile picture";
+ }
+
+ const ImageSource =
+ GetGardenerProfilePictureSource(
+ ProfilePicture
+ );
+
+ if (ImageSource !== null) {
+ const Image =
+ document.createElement(
+ "img"
+ );
+
+ Image.className =
+ "PlantSprite";
+
+ Image.src = ImageSource;
+ Image.alt = "";
+ Image.draggable = false;
+
+ Avatar.appendChild(
+ Image
+ );
+
+ return Avatar;
+ }
+
+ const Placeholder =
+ document.createElement(
+ "span"
+ );
+
+ Placeholder.className =
+ "GardenerAvatarPlaceholder";
+
+ Placeholder.textContent = "□";
+
+ Avatar.appendChild(
+ Placeholder
+ );
+
+ return Avatar;
+}
+
+
+function GetGardenerPageUrl(
+ Username
+) {
+ const Query =
+ new URLSearchParams({
+ Username: Username
+ });
+
+ return (
+ "/Pages/Gardener.html?" +
+ Query.toString()
+ );
+}
+
+
+function CreateGardenerIdentity(
+ Username,
+ Colour,
+ ProfilePicture,
+ LinkToGardener = true
+) {
+ const Container =
+ document.createElement(
+ LinkToGardener
+ ? "a"
+ : "span"
+ );
+
+ Container.className =
+ "GardenerIdentity";
+
+ if (LinkToGardener) {
+ Container.href =
+ GetGardenerPageUrl(
+ Username
+ );
+ }
+
+ const Avatar =
+ CreateGardenerAvatar(
+ ProfilePicture,
+ Username
+ );
+
+ const Name =
+ document.createElement(
+ "span"
+ );
+
+ Name.className =
+ "GardenerIdentityName";
+
+ Name.textContent = Username;
+
+ ApplyPlayerColour(
+ Name,
+ Colour
+ );
+
+ Container.append(
+ Avatar,
+ Name
+ );
+
+ return Container;
+}
+
+
async function GetProfile() {
const Response = await fetch(
ApiUrl + "/Profile.php",
diff --git a/Styles/Style.css b/Styles/Style.css
index cb5c5cc..0d7fa31 100644
--- a/Styles/Style.css
+++ b/Styles/Style.css
@@ -1020,7 +1020,7 @@ input, button, textarea, select {
text-align: right;
}
-.ProfileSection {
+.ProfileSection, .SocialSection {
margin-top: 1.5rem;
padding: 0.75rem;
}
@@ -1029,7 +1029,7 @@ input, button, textarea, select {
margin-top: 1.5rem;
}
-.ProfileFormRow, .ProfileButtonRow {
+.FormRow, .ProfileFormRow, .ProfileButtonRow {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
@@ -1040,13 +1040,13 @@ input, button, textarea, select {
margin-top: 0.75rem;
}
-.ProfileSection label {
+.InteractivePanel label {
display: block;
margin-bottom: 0.5rem;
color: var(--Blue);
}
-.ProfileSection input, .ProfileSection textarea, .ProfileSection select, .ProfileSection button {
+.InteractivePanel input, .InteractivePanel textarea, .InteractivePanel select, .InteractivePanel button {
font-size: 1rem;
color: var(--Text);
background-color: var(--Surface0);
@@ -1055,46 +1055,46 @@ input, button, textarea, select {
appearance: none;
}
-.ProfileSection input, .ProfileSection textarea, .ProfileSection select {
+.InteractivePanel input, .InteractivePanel textarea, .InteractivePanel select {
padding: 0.6rem;
outline: none;
}
-.ProfileSection input {
+.InteractivePanel input {
min-width: 12rem;
}
-.ProfileSection select {
+.InteractivePanel select {
appearance: auto;
}
-.ProfileSection textarea {
+.InteractivePanel textarea {
width: 100%;
max-width: 24rem;
resize: vertical;
}
-.ProfileSection input::placeholder, .ProfileSection textarea::placeholder {
+.InteractivePanel input::placeholder, .InteractivePanel textarea::placeholder {
color: var(--Overlay1);
}
-.ProfileSection input:hover, .ProfileSection textarea:hover, .ProfileSection select:hover {
+.InteractivePanel input:hover, .InteractivePanel textarea:hover, .InteractivePanel select:hover {
background-color: var(--Surface1);
border-color: var(--Overlay2);
}
-.ProfileSection input:focus, .ProfileSection textarea:focus, .ProfileSection select:focus {
+.InteractivePanel input:focus, .InteractivePanel textarea:focus, .InteractivePanel select:focus {
background-color: var(--Surface1);
border-color: var(--Sky);
}
-.ProfileSection button {
+.InteractivePanel button {
padding: 0.6rem 0.8rem;
cursor: pointer;
box-shadow: 3px 3px 0 var(--Crust);
}
-.ProfileSection button:hover {
+.InteractivePanel button:hover:not(:disabled) {
color: var(--Sky);
background-color: var(--Surface1);
border-color: var(--Sky);
@@ -1102,7 +1102,7 @@ input, button, textarea, select {
box-shadow: 2px 2px 0 var(--Crust);
}
-.ProfileSection button:disabled {
+.InteractivePanel button:disabled {
cursor: not-allowed;
color: var(--Overlay1);
background-color: var(--Surface0);
@@ -1110,7 +1110,7 @@ input, button, textarea, select {
box-shadow: none;
}
-.ProfileSection button:active {
+.InteractivePanel button:active:not(:disabled) {
color: var(--Green);
background-color: var(--Surface2);
border-color: var(--Green);
@@ -1118,7 +1118,7 @@ input, button, textarea, select {
box-shadow: 1px 1px 0 var(--Crust);
}
-.ProfileSection button:focus-visible, .ProfileSection summary:focus-visible {
+.InteractivePanel button:focus-visible, .InteractivePanel summary:focus-visible {
outline: 3px solid var(--Yellow);
outline-offset: 3px;
}
@@ -1259,12 +1259,12 @@ input, button, textarea, select {
overflow-wrap: anywhere;
}
- .ProfileFormRow, .ProfileButtonRow {
+ .FormRow, .ProfileFormRow, .ProfileButtonRow {
flex-direction: column;
align-items: stretch;
}
- .ProfileSection input, .ProfileSection textarea, .ProfileSection select, .ProfileSection button {
+ .InteractivePanel input, .InteractivePanel textarea, .InteractivePanel select, .InteractivePanel button {
width: 100%;
}
@@ -1609,6 +1609,264 @@ input, button, textarea, select {
}
}
+
+/* * Garden social */
+
+.GardenerAvatar {
+ display: flex;
+ width: 3rem;
+ height: 3rem;
+ flex: 0 0 3rem;
+ align-items: center;
+ justify-content: center;
+}
+
+.GardenerAvatarPlaceholder {
+ color: var(--Overlay1);
+ font-size: 1.75rem;
+ line-height: 1;
+}
+
+.GardenerIdentity {
+ display: inline-flex;
+ min-width: 0;
+ gap: 0.65rem;
+ align-items: center;
+ color: inherit;
+ text-decoration: none;
+}
+
+.GardenerIdentityName {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.GardenerIdentity:hover .GardenerIdentityName {
+ text-decoration: underline;
+}
+
+.GardenerSearchResults {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.GardenerSearchResult {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.85rem;
+ padding: 0.75rem;
+ align-items: center;
+ color: var(--Text);
+ text-decoration: none;
+ box-shadow: 3px 3px 0 var(--Crust);
+}
+
+.GardenerSearchResult:hover {
+ color: var(--Text);
+ background-color: var(--Surface1);
+ border-color: var(--Sky);
+ transform: translate(1px, 1px);
+ box-shadow: 2px 2px 0 var(--Crust);
+}
+
+.GardenerAvatarSearch {
+ width: 4.25rem;
+ height: 4.25rem;
+ flex-basis: 4.25rem;
+}
+
+.GardenerSearchResultDetails {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.GardenerSearchResultName {
+ overflow: hidden;
+ font-size: 1.35rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.GardenerSearchResultDescription {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--Subtext1);
+ line-height: 1.35;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.GardenerBackLink {
+ margin-bottom: 1rem;
+}
+
+.GardenerHero {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 1rem;
+ align-items: center;
+}
+
+.GardenerHeroText {
+ min-width: 0;
+}
+
+.GardenerHeroText h1 {
+ margin-bottom: 0.5rem;
+}
+
+.GardenerHeroText p {
+ margin-bottom: 0;
+ white-space: pre-wrap;
+}
+
+.GardenerAvatarHero {
+ width: 7rem;
+ height: 7rem;
+ flex-basis: 7rem;
+}
+
+.GardenerSectionHeader {
+ justify-content: space-between;
+}
+
+.GardenerGardenControls {
+ display: grid;
+ grid-template-columns: minmax(7rem, auto) minmax(0, 1fr) minmax(7rem, auto);
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+ align-items: center;
+}
+
+.GardenerGardenTitle {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 0.2rem;
+ align-items: center;
+ text-align: center;
+}
+
+.GardenerGardenTitle strong {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.GardenerGardenTitle span,
+.GardenerSectionHeader span {
+ color: var(--Subtext1);
+}
+
+.GardenPlotReadOnly,
+.GardenPlotReadOnly:hover {
+ cursor: default;
+ transform: none;
+ box-shadow: 3px 3px 0 var(--Crust);
+}
+
+.GardenPlotReadOnly:hover {
+ --TileBackground: var(--Surface0);
+ --PlotBorderColour: var(--Surface2);
+}
+
+.GardenPlotReadOnly.GardenPlotEmpty:hover {
+ --TileBackground: var(--Mantle);
+}
+
+.GardenPlotReadOnly.GardenPlotMature:hover {
+ --GrowthColour: var(--Green);
+}
+
+.GardenerCommentList {
+ display: grid;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.GardenerComment {
+ padding: 0.75rem;
+}
+
+.GardenerCommentHeader {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.GardenerCommentTime {
+ flex: 0 0 auto;
+ color: var(--Overlay1);
+ font-size: 0.85rem;
+ text-align: right;
+}
+
+.GardenerCommentText {
+ margin: 0.75rem 0 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.InteractivePanel .GardenerCommentDelete {
+ width: auto;
+ margin-top: 0.75rem;
+}
+
+.GardenerCommentsEmpty {
+ margin: 0;
+ color: var(--Subtext1);
+}
+
+.GardenerLoadMoreButton {
+ margin-top: 0.75rem;
+}
+
+.LeaderboardTable {
+ font-size: 1.35rem;
+}
+
+.LeaderboardUsername .GardenerAvatar {
+ width: 3.25rem;
+ height: 3.25rem;
+ flex-basis: 3.25rem;
+}
+
+.LeaderboardUsername .GardenerIdentity {
+ width: 100%;
+}
+
+@media (max-width: 600px) {
+ .GardenerAvatarHero {
+ width: 5.5rem;
+ height: 5.5rem;
+ flex-basis: 5.5rem;
+ }
+
+ .GardenerGardenControls {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .GardenerGardenTitle {
+ grid-column: 1 / -1;
+ grid-row: 1;
+ }
+
+ .GardenerCommentHeader {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .GardenerCommentTime {
+ text-align: left;
+ }
+}
+
/* * Garden guide */
.GuideSection {