generated from nhcarrigan/template
5ad2c44399
- Add user-configurable number format (suffix/scientific/engineering) - Suffix: K/M/B/T through Dc (1e33), then letter-based a/b/c... indefinitely - Scientific: 1.23e15 style via toExponential - Engineering: exponent always a multiple of 3 (1.23E15) - Stored in ProfileSettings, fetched from profile API on load - Picker UI in EditProfileModal with live examples - Cap all resource accumulation at 1e300 (RESOURCE_CAP constant) - Per-resource FULL badge with tooltip in ResourceBar - Amber notice strip when any resource is at cap - handleClick also respects the cap - Make EditProfileModal scrollable with viewport margin - Flex column layout with sticky header, scrollable form body - Bio textarea preserved as resizable with min-height - Fix ReferenceError: formatNumber not defined in BossPanel/AchievementPanel - Pass formatNumber as prop to BossCard and AchievementCard - Pass formatNumber as parameter to conditionDescription
164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
import type { PublicProfileResponse } from "@elysium/types";
|
||
import { useEffect, useState } from "react";
|
||
import { useGame } from "../../context/GameContext.js";
|
||
|
||
interface ProfilePageProps {
|
||
discordId: string;
|
||
}
|
||
|
||
export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => {
|
||
const { formatNumber } = useGame();
|
||
const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
useEffect(() => {
|
||
fetch(`/api/profile/${discordId}`)
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error("Player not found");
|
||
return res.json() as Promise<PublicProfileResponse>;
|
||
})
|
||
.then(setProfile)
|
||
.catch((err: unknown) => {
|
||
setError(err instanceof Error ? err.message : "Failed to load profile");
|
||
});
|
||
}, [discordId]);
|
||
|
||
const handleCopy = (): void => {
|
||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => { setCopied(false); }, 2000);
|
||
});
|
||
};
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="profile-page">
|
||
<div className="profile-error">
|
||
<p>⚠️ {error}</p>
|
||
<a className="profile-play-link" href="/">← Play Elysium</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!profile) {
|
||
return (
|
||
<div className="profile-page">
|
||
<div className="profile-loading">Loading profile…</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const s = profile.profileSettings;
|
||
|
||
const avatarUrl = profile.avatar
|
||
? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`
|
||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
|
||
|
||
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
|
||
const visibleStats = [
|
||
s.showTotalGold && {
|
||
icon: "🪙",
|
||
value: formatNumber(profile.totalGoldEarned),
|
||
label: "Total Gold Earned",
|
||
date: false,
|
||
},
|
||
s.showTotalClicks && {
|
||
icon: "👆",
|
||
value: formatNumber(profile.totalClicks),
|
||
label: "Total Clicks",
|
||
date: false,
|
||
},
|
||
s.showBossesDefeated && {
|
||
icon: "💀",
|
||
value: String(profile.bossesDefeated),
|
||
label: "Bosses Defeated",
|
||
date: false,
|
||
},
|
||
s.showQuestsCompleted && {
|
||
icon: "📜",
|
||
value: String(profile.questsCompleted),
|
||
label: "Quests Completed",
|
||
date: false,
|
||
},
|
||
s.showAdventurersRecruited && {
|
||
icon: "⚔️",
|
||
value: formatNumber(profile.adventurersRecruited),
|
||
label: "Adventurers Recruited",
|
||
date: false,
|
||
},
|
||
s.showAchievementsUnlocked && {
|
||
icon: "🏆",
|
||
value: String(profile.achievementsUnlocked),
|
||
label: "Achievements Unlocked",
|
||
date: false,
|
||
},
|
||
s.showGuildFounded && {
|
||
icon: "📅",
|
||
value: memberSince,
|
||
label: "Guild Founded",
|
||
date: true,
|
||
},
|
||
].filter(Boolean) as Array<{ icon: string; value: string; label: string; date: boolean }>;
|
||
|
||
return (
|
||
<div className="profile-page">
|
||
<div className="profile-card">
|
||
<div className="profile-header">
|
||
<img
|
||
alt={`${profile.username}'s avatar`}
|
||
className="profile-avatar"
|
||
src={avatarUrl}
|
||
/>
|
||
<div className="profile-identity">
|
||
<h1 className="profile-character-name">{profile.characterName}</h1>
|
||
<p className="profile-username">@{profile.username}</p>
|
||
{s.showPrestige && profile.prestigeCount > 0 && (
|
||
<span className="profile-prestige-badge">
|
||
⭐ Prestige {profile.prestigeCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{profile.bio && (
|
||
<p className="profile-bio">{profile.bio}</p>
|
||
)}
|
||
|
||
{visibleStats.length > 0 && (
|
||
<div className="profile-stats">
|
||
{visibleStats.map((stat) => (
|
||
<div key={stat.label} className="profile-stat">
|
||
<span className="profile-stat-icon">{stat.icon}</span>
|
||
<span className={`profile-stat-value ${stat.date ? "profile-stat-date" : ""}`}>
|
||
{stat.value}
|
||
</span>
|
||
<span className="profile-stat-label">{stat.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="profile-actions">
|
||
<button
|
||
className="profile-share-button"
|
||
onClick={handleCopy}
|
||
type="button"
|
||
>
|
||
{copied ? "✓ Copied!" : "🔗 Copy Profile Link"}
|
||
</button>
|
||
<a className="profile-play-link" href="/">
|
||
⚔️ Play Elysium
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|