feat: replace resource bar profile buttons with avatar dropdown (#103)

Condenses the Donate, Discord, Support, View Profile, and Edit Profile
buttons into a single avatar image button. Clicking it opens a dropdown
menu with all five actions, significantly decluttering the resource bar.
This commit is contained in:
2026-03-23 14:21:47 -07:00
committed by Naomi Carrigan
parent 4c3b9acfc5
commit fc222ac522
3 changed files with 160 additions and 94 deletions
@@ -135,7 +135,6 @@ const GameLayout = (): JSX.Element => {
); );
} }
const profileUrl = `/profile/${state.player.discordId}`;
const codexBadgeCount = pendingCodexEntryIds.length; const codexBadgeCount = pendingCodexEntryIds.length;
const storyBadgeCount = pendingStoryChapterIds.length; const storyBadgeCount = pendingStoryChapterIds.length;
@@ -160,7 +159,6 @@ const GameLayout = (): JSX.Element => {
onEditProfile={handleOpenEditProfile} onEditProfile={handleOpenEditProfile}
onForceSync={forceSync} onForceSync={forceSync}
prestigeCount={state.prestige.count} prestigeCount={state.prestige.count}
profileUrl={profileUrl}
resources={state.resources} resources={state.resources}
runestones={state.prestige.runestones} runestones={state.prestige.runestones}
transcendenceCount={state.transcendence?.count ?? 0} transcendenceCount={state.transcendence?.count ?? 0}
+96 -49
View File
@@ -5,11 +5,12 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
/* eslint-disable complexity -- Many conditional resource and badge render paths */ /* eslint-disable complexity -- Many conditional resource and badge render paths */
import { useState, type FocusEvent, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
import type { JSX } from "react";
interface ResourceBarProperties { interface ResourceBarProperties {
readonly resources: Resource; readonly resources: Resource;
@@ -17,7 +18,6 @@ interface ResourceBarProperties {
readonly prestigeCount: number; readonly prestigeCount: number;
readonly transcendenceCount: number; readonly transcendenceCount: number;
readonly apotheosisCount: number; readonly apotheosisCount: number;
readonly profileUrl: string;
readonly onEditProfile: ()=> void; readonly onEditProfile: ()=> void;
readonly lastSavedAt: number | null; readonly lastSavedAt: number | null;
readonly isSyncing: boolean; readonly isSyncing: boolean;
@@ -58,7 +58,6 @@ const resourceFullTooltip = [
* @param props.prestigeCount - The number of prestiges completed. * @param props.prestigeCount - The number of prestiges completed.
* @param props.transcendenceCount - The number of transcendences completed. * @param props.transcendenceCount - The number of transcendences completed.
* @param props.apotheosisCount - The number of apotheoses completed. * @param props.apotheosisCount - The number of apotheoses completed.
* @param props.profileUrl - The URL of the player's public profile.
* @param props.onEditProfile - Callback to open the edit profile modal. * @param props.onEditProfile - Callback to open the edit profile modal.
* @param props.lastSavedAt - Timestamp of the last cloud save. * @param props.lastSavedAt - Timestamp of the last cloud save.
* @param props.isSyncing - Whether a sync is currently in progress. * @param props.isSyncing - Whether a sync is currently in progress.
@@ -71,13 +70,14 @@ const ResourceBar = ({
prestigeCount, prestigeCount,
transcendenceCount, transcendenceCount,
apotheosisCount, apotheosisCount,
profileUrl,
onEditProfile, onEditProfile,
lastSavedAt, lastSavedAt,
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProperties): JSX.Element => { }: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError, state } = useGame(); const { formatNumber, syncError, state } = useGame();
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
const { gold, essence, crystals } = resources; const { gold, essence, crystals } = resources;
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0; let goldPerSecond = 0;
@@ -88,6 +88,17 @@ const ResourceBar = ({
} }
goldPerSecond = computeGoldPerSecond(state); goldPerSecond = computeGoldPerSecond(state);
} }
let avatarUrl: string | null = null;
if (state !== null) {
avatarUrl = state.player.avatar === null
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
}
const profileUrl = state === null
? "#"
: `/profile/${state.player.discordId}`;
const resourceValues = [ gold, essence, crystals ]; const resourceValues = [ gold, essence, crystals ];
const anyFull = resourceValues.some((v) => { const anyFull = resourceValues.some((v) => {
return v >= RESOURCE_CAP; return v >= RESOURCE_CAP;
@@ -100,6 +111,23 @@ const ResourceBar = ({
void onForceSync(); void onForceSync();
} }
function handleToggleProfile(): void {
setIsProfileOpen((previous) => {
return !previous;
});
}
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsProfileOpen(false);
}
}
function handleEditProfile(): void {
setIsProfileOpen(false);
onEditProfile();
}
return ( return (
<> <>
<header className="resource-bar"> <header className="resource-bar">
@@ -174,34 +202,7 @@ const ResourceBar = ({
{prestigeCount} {prestigeCount}
</div> </div>
} }
<div className="profile-buttons"> <div className="resource-bar-actions">
<a
className="profile-link-button"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Support the developer"
>
{"💜"} <span className="btn-label">{"Donate"}</span>
</a>
<a
className="profile-link-button"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Join our Discord"
>
{"💬"} <span className="btn-label">{"Discord"}</span>
</a>
<a
className="profile-link-button"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Get support on our forum"
>
{"🆘"} <span className="btn-label">{"Support"}</span>
</a>
{syncError === null {syncError === null
? null ? null
: <span className="save-status save-error" title={syncError}> : <span className="save-status save-error" title={syncError}>
@@ -228,23 +229,69 @@ const ResourceBar = ({
? "⏳" ? "⏳"
: "💾"} : "💾"}
</button> </button>
<a {avatarUrl === null
className="profile-link-button" ? null
href={profileUrl} : <div
rel="noreferrer" className="profile-menu"
target="_blank" onBlur={handleProfileBlur}
title="View your public profile" >
> <button
{"👤"} <span className="btn-label">{"Profile"}</span> className="profile-avatar-button"
</a> onClick={handleToggleProfile}
<button title="Account"
className="profile-edit-button" type="button"
onClick={onEditProfile} >
title="Edit your profile" <img
type="button" alt="Profile"
> className="profile-avatar-img"
{"✏️"} src={avatarUrl}
</button> />
</button>
{isProfileOpen
? <div className="profile-dropdown">
<a
className="profile-dropdown-item"
href={profileUrl}
rel="noreferrer"
target="_blank"
>
{"👤 View Profile"}
</a>
<button
className="profile-dropdown-item"
onClick={handleEditProfile}
type="button"
>
{"✏️ Edit Profile"}
</button>
<hr className="profile-dropdown-divider" />
<a
className="profile-dropdown-item"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💜 Donate"}
</a>
<a
className="profile-dropdown-item"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💬 Discord"}
</a>
<a
className="profile-dropdown-item"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"🆘 Support"}
</a>
</div>
: null}
</div>}
</div> </div>
</header> </header>
{anyFull {anyFull
+64 -43
View File
@@ -1492,57 +1492,87 @@ body::before {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */ /* ── Resource bar actions (save + profile menu) ─────────────────────────── */
.profile-buttons { .resource-bar-actions {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
margin-left: auto; margin-left: auto;
} }
.profile-link-button { .profile-menu {
align-items: center; position: relative;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 1rem;
color: var(--colour-text-muted);
display: flex;
font-size: 0.8rem;
gap: 0.3rem;
padding: 0.3rem 0.8rem;
text-decoration: none;
transition: all 0.2s;
white-space: nowrap;
} }
.profile-link-button:hover { .profile-avatar-button {
background: rgba(147, 51, 234, 0.2); background: none;
border-color: var(--colour-primary); border: 2px solid rgba(147, 51, 234, 0.4);
color: var(--colour-text);
}
.profile-edit-button {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 50%; border-radius: 50%;
color: var(--colour-text-muted);
cursor: pointer; cursor: pointer;
font-family: inherit; display: flex;
font-size: 0.85rem;
height: 2rem; height: 2rem;
line-height: 1; overflow: hidden;
padding: 0; padding: 0;
transition: all 0.2s; transition: border-color 0.2s;
width: 2rem; width: 2rem;
} }
.profile-edit-button:hover { .profile-avatar-button:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary); border-color: var(--colour-primary);
}
.profile-avatar-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.profile-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
min-width: 10rem;
padding: 0.25rem;
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 100;
}
.profile-dropdown-item {
align-items: center;
background: none;
border: none;
border-radius: 0.35rem;
color: var(--colour-text-muted);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: 0.85rem;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
text-align: left;
text-decoration: none;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
width: 100%;
}
.profile-dropdown-item:hover {
background: rgba(147, 51, 234, 0.15);
color: var(--colour-text); color: var(--colour-text);
} }
.profile-dropdown-divider {
border: none;
border-top: 1px solid rgba(147, 51, 234, 0.2);
margin: 0.25rem 0;
}
.save-status { .save-status {
color: var(--colour-text-muted); color: var(--colour-text-muted);
font-size: 0.75rem; font-size: 0.75rem;
@@ -3167,10 +3197,10 @@ body::before {
display: none; display: none;
} }
/* Profile buttons fill their own row, aligned right */ /* Resource bar actions fill their own row, aligned right */
.profile-buttons { .resource-bar-actions {
margin-left: 0;
justify-content: flex-end; justify-content: flex-end;
margin-left: 0;
width: 100%; width: 100%;
} }
@@ -3240,15 +3270,6 @@ body::before {
/* --- Small mobile (≤ 480px) --------------------------- */ /* --- Small mobile (≤ 480px) --------------------------- */
@media (max-width: 480px) { @media (max-width: 480px) {
/* Icon-only profile link buttons to save horizontal space */
.btn-label {
display: none;
}
.profile-link-button {
padding: 0.3rem 0.5rem;
}
/* Slightly smaller tab buttons */ /* Slightly smaller tab buttons */
.tab-button { .tab-button {
font-size: 0.8rem; font-size: 0.8rem;