From 3ff17bda842f4cb2fbed9ead27b988ec04b6a74a Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 23:16:50 -0800 Subject: [PATCH] feat: add about page with versions, changelog, and how-to-play - New GET /about API endpoint caches Gitea releases for 5 minutes - AboutPanel displays client version (via Vite define), API version, collapsible changelog, and How to Play guide - GiteaRelease and AboutResponse types added to shared package --- apps/api/package.json | 2 +- apps/api/src/index.ts | 2 + apps/api/src/routes/about.ts | 46 +++++++ apps/web/package.json | 2 +- apps/web/src/api/client.ts | 4 + apps/web/src/components/game/AboutPanel.tsx | 135 ++++++++++++++++++++ apps/web/src/components/game/GameLayout.tsx | 5 +- apps/web/src/styles.css | 133 +++++++++++++++++++ apps/web/src/vite-env.d.ts | 3 + apps/web/vite.config.ts | 8 ++ package.json | 2 +- packages/types/package.json | 2 +- packages/types/src/index.ts | 2 + packages/types/src/interfaces/Api.ts | 12 ++ 14 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/routes/about.ts create mode 100644 apps/web/src/components/game/AboutPanel.tsx create mode 100644 apps/web/src/vite-env.d.ts diff --git a/apps/api/package.json b/apps/api/package.json index 21f5adc..8f2f50f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@elysium/api", - "version": "1.0.0", + "version": "0.0.0", "private": true, "type": "module", "main": "./prod/src/index.js", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f0d2513..8005ed5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,6 +2,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; +import { aboutRouter } from "./routes/about.js"; import { authRouter } from "./routes/auth.js"; import { bossRouter } from "./routes/boss.js"; import { gameRouter } from "./routes/game.js"; @@ -20,6 +21,7 @@ app.use( }), ); +app.route("/about", aboutRouter); app.route("/auth", authRouter); app.route("/game", gameRouter); app.route("/boss", bossRouter); diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts new file mode 100644 index 0000000..afbb159 --- /dev/null +++ b/apps/api/src/routes/about.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Hono } from "hono"; +import type { AboutResponse, GiteaRelease } from "@elysium/types"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const { version: API_VERSION } = JSON.parse( + readFileSync(join(__dirname, "../../package.json"), "utf-8"), +) as { version: string }; + +const GITEA_RELEASES_URL = + "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan-ideation/elysium/releases"; +const CACHE_TTL_MS = 5 * 60 * 1000; + +let releasesCache: GiteaRelease[] = []; +let cacheTimestamp = 0; + +const fetchReleases = async (): Promise => { + const now = Date.now(); + if (releasesCache.length > 0 && now - cacheTimestamp < CACHE_TTL_MS) { + return releasesCache; + } + try { + const response = await fetch(GITEA_RELEASES_URL); + if (!response.ok) { + return releasesCache; + } + releasesCache = (await response.json()) as GiteaRelease[]; + cacheTimestamp = now; + return releasesCache; + } catch { + return releasesCache; + } +}; + +export const aboutRouter = new Hono(); + +aboutRouter.get("/", async (context) => { + const releases = await fetchReleases(); + const body: AboutResponse = { + apiVersion: API_VERSION, + releases, + }; + return context.json(body); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 3f2bb26..b1ca228 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@elysium/web", - "version": "1.0.0", + "version": "0.0.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 23e74de..260dbb3 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,5 @@ import type { + AboutResponse, AuthResponse, BossChallengeRequest, BossChallengeResponse, @@ -45,6 +46,9 @@ const request = async ( return response.json() as Promise; }; +export const getAbout = async (): Promise => + request("/about"); + export const getAuthUrl = async (): Promise => { const data = await request<{ url: string }>("/auth/url"); return data.url; diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx new file mode 100644 index 0000000..02c84d4 --- /dev/null +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react"; +import { getAbout } from "../../api/client.js"; +import type { AboutResponse } from "@elysium/types"; + +const HOW_TO_PLAY = [ + { + title: "âš”ī¸ Adventurers", + body: "Hire adventurers to earn gold and essence automatically. Each tier is more powerful than the last. Adventurers also contribute combat power for boss fights — the more you recruit, the stronger your party becomes.", + }, + { + title: "👆 Clicking", + body: "Click the guild hall to earn gold manually. Upgrades and equipment can dramatically increase your gold per click. Clicking is especially powerful in the early game and when saving up for big purchases.", + }, + { + title: "🔧 Upgrades", + body: "Purchase upgrades to multiply the gold and essence output of specific adventurer tiers, or boost your whole guild. Upgrades are permanent for the current run and compound with each other.", + }, + { + title: "📜 Quests", + body: "Send your guild on quests that complete over time and reward gold, essence, crystals, equipment, and upgrades. Multiple quests can run simultaneously. Completing quests also unlocks new zones.", + }, + { + title: "👹 Boss Fights", + body: "Challenge zone bosses to earn large one-time rewards and unlock new zones. Your party's combat power is based on the number and tier of adventurers you've recruited. Defeated bosses cannot be re-fought, but undefeated bosses regenerate HP over time.", + }, + { + title: "đŸ—ēī¸ Zones", + body: "New zones unlock when you defeat the final boss AND complete the final quest of the previous zone. Each zone contains new bosses and quests with progressively greater rewards.", + }, + { + title: "đŸ—Ąī¸ Equipment", + body: "Earn equipment from boss drops and quest rewards. Each piece of equipment provides bonuses to gold income, click power, or adventurer output. Rarer equipment provides stronger bonuses.", + }, + { + title: "⭐ Prestige", + body: "When you've progressed far enough, you can prestige to earn runestones — a permanent currency that persists across all runs. Prestige resets your current run but grants a production multiplier that stacks with every prestige. Name your prestige character to commemorate the run!", + }, + { + title: "🔮 Runestones & Prestige Upgrades", + body: "Spend runestones in the Prestige Shop on permanent upgrades that carry over across all future runs. These upgrades multiply income, click power, essence, and crystal gain — making each new run more powerful than the last.", + }, + { + title: "🏆 Achievements", + body: "Earn achievements by hitting milestones — total gold earned, bosses defeated, quests completed, and more. Achievements are purely cosmetic and track your long-term progress across all prestige runs.", + }, + { + title: "📅 Daily Challenges", + body: "Complete daily challenges for bonus rewards including gold, essence, crystals, and runestones. Challenges reset each day and vary in difficulty. Completing all daily challenges gives an extra bonus reward.", + }, + { + title: "â˜ī¸ Cloud Saves", + body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.", + }, +]; + +const formatDate = (dateStr: string): string => + new Date(dateStr).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + +export const AboutPanel = (): React.JSX.Element => { + const [about, setAbout] = useState(null); + const [error, setError] = useState(null); + const [expandedRelease, setExpandedRelease] = useState(null); + + useEffect(() => { + getAbout() + .then(setAbout) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load about data."); + }); + }, []); + + return ( +
+

â„šī¸ About

+ +
+
+ 🌐 Client Version + {__WEB_VERSION__} +
+
+ âš™ī¸ API Version + {about?.apiVersion ?? "Loading..."} +
+
+ +

📋 Changelog

+ {error !== null &&

{error}

} + {about === null && error === null &&

Loading changelog...

} + {about !== null && about.releases.length === 0 && ( +

No releases yet.

+ )} + {about !== null && about.releases.length > 0 && ( +
    + {about.releases.map((release) => ( +
  • + + {expandedRelease === release.tag_name && ( +
    {release.body}
    + )} +
  • + ))} +
+ )} + +

📖 How to Play

+
    + {HOW_TO_PLAY.map((section) => ( +
  • +

    {section.title}

    +

    {section.body}

    +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index 684f530..5e43cd4 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useGame } from "../../context/GameContext.js"; import { ResourceBar } from "../ui/ResourceBar.js"; +import { AboutPanel } from "./AboutPanel.js"; import { AchievementPanel } from "./AchievementPanel.js"; import { AchievementToast } from "./AchievementToast.js"; import { AdventurerPanel } from "./AdventurerPanel.js"; @@ -16,7 +17,7 @@ import { StatisticsPanel } from "./StatisticsPanel.js"; import { UpgradePanel } from "./UpgradePanel.js"; import { DailyChallengePanel } from "./DailyChallengePanel.js"; -type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily"; +type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily" | "about"; const TABS: { id: Tab; label: string }[] = [ { id: "adventurers", label: "âš”ī¸ Adventurers" }, @@ -28,6 +29,7 @@ const TABS: { id: Tab; label: string }[] = [ { id: "prestige", label: "⭐ Prestige" }, { id: "statistics", label: "📊 Statistics" }, { id: "daily", label: "📅 Daily" }, + { id: "about", label: "â„šī¸ About" }, ]; export const GameLayout = (): React.JSX.Element => { @@ -104,6 +106,7 @@ export const GameLayout = (): React.JSX.Element => { {activeTab === "prestige" && } {activeTab === "statistics" && } {activeTab === "daily" && } + {activeTab === "about" && } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 93fc982..47f0074 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1942,3 +1942,136 @@ body { height: 100%; transition: width 0.3s ease; } + +/* ========== About Panel ========== */ + +.about-versions { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.about-version-card { + background: var(--colour-bg-secondary, #16213e); + border: 1px solid var(--colour-border, #0f3460); + border-radius: 8px; + padding: 0.75rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 160px; +} + +.about-version-label { + color: var(--colour-text-muted); + font-size: 0.8rem; +} + +.about-version-value { + color: var(--colour-accent); + font-size: 1.1rem; + font-weight: bold; + font-family: monospace; +} + +.about-loading, +.about-empty, +.about-error { + color: var(--colour-text-muted); + font-size: 0.9rem; +} + +.about-error { + color: var(--colour-danger, #e74c3c); +} + +.about-releases { + list-style: none; + padding: 0; + margin: 0 0 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.about-release { + background: var(--colour-bg-secondary, #16213e); + border: 1px solid var(--colour-border, #0f3460); + border-radius: 8px; + overflow: hidden; +} + +.about-release-header { + width: 100%; + background: none; + border: none; + cursor: pointer; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--colour-text-primary, #e0e0e0); + text-align: left; +} + +.about-release-header:hover { + background: rgba(255, 255, 255, 0.05); +} + +.about-release-tag { + font-weight: bold; + font-size: 0.95rem; + flex: 1; +} + +.about-release-date { + color: var(--colour-text-muted); + font-size: 0.8rem; +} + +.about-release-chevron { + color: var(--colour-text-muted); + font-size: 0.75rem; +} + +.about-release-body { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + font-size: 0.85rem; + color: var(--colour-text-secondary, #b0b0b0); + padding: 0 1rem 0.75rem; + margin: 0; + border-top: 1px solid var(--colour-border, #0f3460); +} + +.about-how-to-play { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.about-htp-section { + background: var(--colour-bg-secondary, #16213e); + border: 1px solid var(--colour-border, #0f3460); + border-radius: 8px; + padding: 0.75rem 1rem; +} + +.about-htp-title { + font-size: 0.95rem; + font-weight: bold; + color: var(--colour-accent); + margin: 0 0 0.4rem; +} + +.about-htp-body { + font-size: 0.85rem; + color: var(--colour-text-secondary, #b0b0b0); + margin: 0; + line-height: 1.5; +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..cad8a0a --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __WEB_VERSION__: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 92d6218..a26d4ce 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,7 +1,15 @@ +import { readFileSync } from "node:fs"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +const { version: WEB_VERSION } = JSON.parse( + readFileSync("./package.json", "utf-8"), +) as { version: string }; + export default defineConfig({ + define: { + __WEB_VERSION__: JSON.stringify(WEB_VERSION), + }, plugins: [react()], server: { port: 5173, diff --git a/package.json b/package.json index 4601456..8c009e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "elysium", - "version": "1.0.0", + "version": "0.0.0", "private": true, "scripts": { "lint": "pnpm -r lint", diff --git a/packages/types/package.json b/packages/types/package.json index abfd749..28df699 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@elysium/types", - "version": "1.0.0", + "version": "0.0.0", "private": true, "main": "./prod/src/index.js", "types": "./prod/src/index.d.ts", diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 68226e7..80bb3c3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,12 +6,14 @@ export type { } from "./interfaces/Achievement.js"; export type { Adventurer, AdventurerClass } from "./interfaces/Adventurer.js"; export type { + AboutResponse, ApiError, AuthResponse, BossChallengeRequest, BossChallengeResponse, BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeResponse, + GiteaRelease, LoadResponse, PrestigeRequest, PrestigeResponse, diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index 8736e24..519f124 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -119,5 +119,17 @@ export interface ApiError { error: string; } +export interface GiteaRelease { + tag_name: string; + name: string; + body: string; + published_at: string; +} + +export interface AboutResponse { + apiVersion: string; + releases: GiteaRelease[]; +} + // Re-export for convenience export type { ProfileSettings };