generated from nhcarrigan/template
5aae3eb389
Add syncError state to GameContext. forceSync now catches errors
and displays them in the ResourceBar for 5 seconds, replacing the
cloud save timestamp with '❌ Save failed'. Signature mismatches
are also cleared from localStorage so the next save can proceed.
Auto-save silently self-heals bad signatures without surfacing an error.
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
import type { Resource } from "@elysium/types";
|
||
import { useGame } from "../../context/GameContext.js";
|
||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
||
|
||
interface ResourceBarProps {
|
||
resources: Resource;
|
||
prestigeCount: number;
|
||
profileUrl: string;
|
||
onEditProfile: () => void;
|
||
lastSavedAt: number | null;
|
||
isSyncing: boolean;
|
||
onForceSync: () => Promise<void>;
|
||
}
|
||
|
||
const formatRelativeTime = (timestamp: number): string => {
|
||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||
if (seconds < 10) return "just now";
|
||
if (seconds < 60) return `${seconds}s ago`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return `${minutes}m ago`;
|
||
const hours = Math.floor(minutes / 60);
|
||
return `${hours}h ago`;
|
||
};
|
||
|
||
const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning.";
|
||
|
||
export const ResourceBar = ({
|
||
resources,
|
||
prestigeCount,
|
||
profileUrl,
|
||
onEditProfile,
|
||
lastSavedAt,
|
||
isSyncing,
|
||
onForceSync,
|
||
}: ResourceBarProps): React.JSX.Element => {
|
||
const { formatNumber, syncError } = useGame();
|
||
const anyFull = Object.values(resources).some((v) => v >= RESOURCE_CAP);
|
||
return (
|
||
<>
|
||
<header className="resource-bar">
|
||
<div className={`resource${resources.gold >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||
<span className="resource-icon">🪙</span>
|
||
<span className="resource-value">{formatNumber(resources.gold)}</span>
|
||
<span className="resource-label">Gold</span>
|
||
{resources.gold >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||
</div>
|
||
<div className={`resource${resources.essence >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||
<span className="resource-icon">✨</span>
|
||
<span className="resource-value">{formatNumber(resources.essence)}</span>
|
||
<span className="resource-label">Essence</span>
|
||
{resources.essence >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||
</div>
|
||
<div className={`resource${resources.crystals >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||
<span className="resource-icon">💎</span>
|
||
<span className="resource-value">{formatNumber(resources.crystals)}</span>
|
||
<span className="resource-label">Crystals</span>
|
||
{resources.crystals >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||
</div>
|
||
<div className={`resource${resources.runestones >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||
<span className="resource-icon">🔮</span>
|
||
<span className="resource-value">{formatNumber(resources.runestones)}</span>
|
||
<span className="resource-label">Runestones</span>
|
||
{resources.runestones >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||
</div>
|
||
{prestigeCount > 0 && (
|
||
<div className="prestige-badge">
|
||
⭐ Prestige {prestigeCount}
|
||
</div>
|
||
)}
|
||
<div className="profile-buttons">
|
||
{syncError !== null ? (
|
||
<span className="save-status save-error" title={syncError}>
|
||
❌ Save failed
|
||
</span>
|
||
) : lastSavedAt !== null ? (
|
||
<span className="save-status" title={new Date(lastSavedAt).toLocaleString()}>
|
||
☁️ {formatRelativeTime(lastSavedAt)}
|
||
</span>
|
||
) : null}
|
||
<button
|
||
className="force-save-button"
|
||
disabled={isSyncing}
|
||
onClick={onForceSync}
|
||
title="Force cloud save"
|
||
type="button"
|
||
>
|
||
{isSyncing ? "⏳" : "💾"}
|
||
</button>
|
||
<a
|
||
className="profile-link-button"
|
||
href={profileUrl}
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
title="View your public profile"
|
||
>
|
||
👤 Profile
|
||
</a>
|
||
<button
|
||
className="profile-edit-button"
|
||
onClick={onEditProfile}
|
||
title="Edit your profile"
|
||
type="button"
|
||
>
|
||
✏️
|
||
</button>
|
||
</div>
|
||
</header>
|
||
{anyFull && (
|
||
<div className="resource-cap-notice">
|
||
⚠️ One or more resources are full! Consider spending some or prestiging to keep earning.
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|