diff --git a/Pages/Gardener.php b/Pages/Gardener.php new file mode 100644 index 0000000..f86b497 --- /dev/null +++ b/Pages/Gardener.php @@ -0,0 +1,228 @@ + + + + + + + + + + +
+ + +
+
+ +
+

Gardener

+ +

+ Loading gardener... +

+
+
+ +

+ Loading gardener... +

+ + + + + + + + +
+ + + + + + + + + + diff --git a/Pages/Guide.php b/Pages/Guide.php index 82c3f10..851fd35 100644 --- a/Pages/Guide.php +++ b/Pages/Guide.php @@ -312,11 +312,12 @@ $PageSection = "Guide";

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";

+
+

Social and Gardener profiles

+ +

+ 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. +

+
+
This browser stores your Account Key and a local copy of your Garden save so the same account can keep working - between visits. If you set a username, some profile - information and statistics can also appear on the - public leaderboard. + between visits. If you set a username, your Gardener can + be found through Social. Your username, profile colour, + description, and selected plant profile picture form the + basic public Gardener profile; the separate privacy + settings control Garden previews, comments, Dew donations, + and public statistics.

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";

Player

@@ -101,7 +101,7 @@ $PageTitle = "Profile";

Gardener profile

@@ -226,6 +226,8 @@ $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.

@@ -267,7 +269,7 @@ $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 other gardeners, visit their public profiles, + preview their Gardens, leave comments, and donate Dew + when they allow it. +

+ + +
- 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 {