step 9.3: done
This commit is contained in:
+27
-34
@@ -4,8 +4,8 @@
|
||||
> If this file disagrees with the code, trust the code and fix this file.
|
||||
|
||||
## 1. Where we are
|
||||
- **Last completed step:** 9.2 (seed-dev script — creates demo account + hero) — TICKED in STATUS.md
|
||||
- **Next step:** Phase 9 — 9.3 (Global error pages / friendly UI error toasts. Loading states.)
|
||||
- **Last completed step:** 9.3 (error pages, shared toast system, loading states) — TICKED in STATUS.md
|
||||
- **Next step:** Phase 9 — 9.4 (Mobile-responsive pass per UI_GUIDE)
|
||||
- **Updated:** 2026-10-21
|
||||
- **Was the last session cut off mid-task?** No
|
||||
|
||||
@@ -14,37 +14,29 @@
|
||||
|---|---|
|
||||
| `npm install` | succeeds |
|
||||
| `npm run migrate` | applies 001–006 / "nothing to do" |
|
||||
| `npm test` | **172 passing, 0 failing** (85 unit + 87 integration) |
|
||||
| `npm test` | **175 passing, 0 failing** (85 unit + 90 integration) |
|
||||
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
|
||||
|
||||
## 3. What exists now (files that matter)
|
||||
- `public/profile.html` — Public hero profile page (reads ?name=, displays stats/equipment).
|
||||
- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile, handles 404.
|
||||
- `public/404.html` — Custom 404 error page (branding: "Page Not Found", Emberfall theme).
|
||||
- `public/500.html` — Custom 500 error page (branding: "Something went wrong", retry button).
|
||||
- `public/js/toast.js` — Shared toast notification system (window.showToast, 3 types: error/info/success).
|
||||
- `public/game.html` — Dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition).
|
||||
- `public/js/game.js` — hunt UI + inventory UI + expedition UI.
|
||||
- `public/js/game.js` — hunt UI + inventory UI + expedition UI (uses shared toast.js).
|
||||
- `public/shop.html` — Dedicated shop page.
|
||||
- `public/login.html`, `public/register.html`, `public/create-hero.html` — Auth & hero creation.
|
||||
- `public/js/shop.js` — buy/sell handlers (uses shared toast.js).
|
||||
- `public/login.html`, `public/register.html`, `public/create-hero.html` — Auth & hero creation (all include toast.js).
|
||||
- `public/profile.html` — Public hero profile page.
|
||||
- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile.
|
||||
- `public/js/auth.js` — Shared API wrapper + showError.
|
||||
- `public/css/main.css` — All styles including spinner, toast, button loading states, error page shared CSS.
|
||||
- `scripts/seed-dev.js` — Creates demo account + hero + starter gear (idempotent, skips in production).
|
||||
- `src/services/heroProfileService.js` — `getHeroProfile(heroName)`.
|
||||
- `src/routes/heroProfile.js` — `GET /api/heroes/:name` (public).
|
||||
- `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`.
|
||||
- `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills`.
|
||||
- `src/services/expeditionService.js` — start, getActive, claim, listDefinitions.
|
||||
- `src/routes/expeditions.js` — 4 expedition endpoints.
|
||||
- `src/services/shopService.js` — getStock, buyItem, sellItem.
|
||||
- `src/routes/shop.js` — GET/POST shop endpoints.
|
||||
- `src/services/inventoryService.js` — listInventory, equipItem, unequipItem, useConsumable.
|
||||
- `src/services/huntService.js` — hunt (one transaction).
|
||||
- `src/services/characterService.js` — createCharacter, getCharacterProfile, getCharacterIdForUser.
|
||||
- `src/services/authService.js` — register, verifyLogin.
|
||||
- `src/game/combat.js` — resolveCombat.
|
||||
- `src/game/stats.js` — calcDerived, xpToNext, checkLevelUp.
|
||||
- `src/game/energy.js` — computeEnergy.
|
||||
- `src/utils/time.js` — now()/setNow()/resetNow().
|
||||
- `src/app.js` — Express app with helmet, rate limiters (auth + general /api), all routes.
|
||||
- `src/middleware/session.js` — session store, requireAuth.
|
||||
- `src/config/index.js` — env validation, frozen config.
|
||||
- `tests/integration/*.test.js` — 87 integration tests across auth, combat, hunt, inventory, shop, expeditions, profile, leaderboard.
|
||||
- `src/app.js` — Express app with helmet, rate limiters, catch-all 404, 500 HTML serving.
|
||||
- `src/services/*` — All game services (auth, character, hunt, inventory, shop, expedition, leaderboard, profile).
|
||||
- `src/routes/*` — All route handlers.
|
||||
- `src/game/*` — Pure game logic (combat, stats, energy, leveling, rewards).
|
||||
- `tests/integration/error-pages.test.js` — 3 tests for 404/500 page serving.
|
||||
- `tests/integration/*.test.js` — 90 integration tests across auth, combat, hunt, inventory, shop, expeditions, profile, leaderboard, error-pages.
|
||||
- `tests/unit/*.test.js` — 85 unit tests for game logic.
|
||||
|
||||
## 4. Half-finished / fragile things
|
||||
@@ -55,7 +47,6 @@
|
||||
- Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md).
|
||||
- Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation.
|
||||
- `GET /api/zones` documented in API.md but not implemented (Issue #2).
|
||||
- Step 9.3 (error pages/toasts/loading states) not yet implemented.
|
||||
|
||||
## 5. Decisions & deviations from the docs
|
||||
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
|
||||
@@ -87,11 +78,13 @@
|
||||
- Profile page is public: no auth.js guard needed; uses ?name= query param for lookup.
|
||||
- Rate limiters in tests: set `NODE_ENV=test` to get 1000 req/window limit; otherwise tests fail with 429.
|
||||
- `seed-dev.js` must check `NODE_ENV=production` before any `require()` that loads config (config throws on placeholder SECRET).
|
||||
- Express `sendFile` rejects `../` path segments — use `path.resolve()` + `path.join()` for absolute paths.
|
||||
- `express.static` sends its own 404 responses; catch-all must be placed BEFORE static middleware.
|
||||
|
||||
## 7. Next-step briefing (9.3)
|
||||
- **Goal:** Add global error pages / friendly UI error toasts. Loading states.
|
||||
- **Docs:** UI_GUIDE, SECURITY (for error handling), TESTING.
|
||||
- Should: add a generic 404/500 error page template in `public/`.
|
||||
- Should: add a toast notification system (JS + CSS) for user-facing errors.
|
||||
- Should: add loading spinners/states for async operations (hunt, buy, etc.).
|
||||
## 7. Next-step briefing (9.4)
|
||||
- **Goal:** Mobile-responsive pass per UI_GUIDE (< 700px: single column, tabs become bottom nav).
|
||||
- **Docs:** UI_GUIDE §game.html layout (Mobile section).
|
||||
- Current CSS has some `@media (max-width: 600px)` rules but needs comprehensive mobile review.
|
||||
- Should: verify all pages render correctly on mobile viewports.
|
||||
- Should: tabs → bottom nav on mobile (per UI_GUIDE).
|
||||
- Should NOT introduce new dependencies — use vanilla HTML/CSS/JS.
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Current Focus
|
||||
- **Phase:** 9
|
||||
- **Step:** 9.3
|
||||
- **Step:** 9.4
|
||||
- **Blocked?** No
|
||||
- **Last verified working command:** `npm test` — 172/172 passing (85 unit + 87 integration), 2026-10-21 step 9.2
|
||||
- **Last verified working command:** `npm test` — 175/175 passing (85 unit + 90 integration), 2026-10-21 step 9.3
|
||||
|
||||
## Project Health
|
||||
| Item | State |
|
||||
@@ -109,7 +109,7 @@
|
||||
*Fixed:* removed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent. Added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec).
|
||||
- [x] **9.2** Add `npm run seed:dev` creating a demo account and hero.
|
||||
*Acceptance:* `npm run seed:dev` creates migrations + demo user + hero + starter gear; safe to re-run; skipped in production.
|
||||
- [ ] **9.3** Global error pages / friendly UI error toasts. Loading states.
|
||||
- [x] **9.3** Global error pages / friendly UI error toasts. Loading states.
|
||||
- [ ] **9.4** Mobile-responsive pass (see UI_GUIDE).
|
||||
- [ ] **9.5** Final full run of "Definition of playable" from NOTES.md §7. Record results below.
|
||||
- [ ] **9.6** Update README with real run instructions.
|
||||
@@ -192,3 +192,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
|
||||
- 2026-10-21 | 8.2 | Created `src/services/heroProfileService.js` (getHeroProfile), `src/routes/heroProfile.js` (GET /api/heroes/:name, public), integrated into app.js; 5 integration tests covering public lookup, case-insensitivity, equipped items, 404, no-auth access | 167/167 tests pass
|
||||
- 2026-10-21 | 9.1 | Walked SECURITY.md checklist: fixed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent; added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec); verified all 9 checklist items pass | 172/172 tests pass
|
||||
- 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass
|
||||
- 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Emberfall — Page Not Found</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<style>
|
||||
.error-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent, #e2822b);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text, #eadfce);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
color: var(--muted, #a59a8a);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-actions a {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.error-actions a:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
background: var(--accent, #e2822b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: transparent;
|
||||
color: var(--accent, #e2822b);
|
||||
border: 1px solid var(--accent, #e2822b);
|
||||
}
|
||||
|
||||
/* Server-only error (500) */
|
||||
.error-code.server {
|
||||
color: var(--danger, #c0392b);
|
||||
}
|
||||
|
||||
.error-message.server {
|
||||
color: var(--muted, #a59a8a);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-code" id="error-code">404</div>
|
||||
<h1 class="error-title" id="error-title">Page Not Found</h1>
|
||||
<p class="error-message" id="error-message">
|
||||
The page you're looking for has gone dark. Perhaps it was never here, or has been consumed by the shadows.
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<a href="/" class="btn-home">Return to Emberfall</a>
|
||||
<a href="javascript:history.back()" class="btn-back">Go Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Emberfall — Server Error</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<style>
|
||||
.error-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: bold;
|
||||
color: var(--danger, #c0392b);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text, #eadfce);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
color: var(--muted, #a59a8a);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-actions a {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.error-actions a:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
background: var(--accent, #e2822b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
background: transparent;
|
||||
color: var(--accent, #e2822b);
|
||||
border: 1px solid var(--accent, #e2822b);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-retry:hover {
|
||||
background: var(--accent, #e2822b);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-code">500</div>
|
||||
<h1 class="error-title">Something went wrong</h1>
|
||||
<p class="error-message">
|
||||
The flames of Emberfall have flickered. Our servers are working to restore the light. Please try again.
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<a href="/" class="btn-home">Return to Emberfall</a>
|
||||
<button class="btn-retry" onclick="window.location.reload()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,6 +26,7 @@
|
||||
<button type="submit">Begin Your Journey</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/create-hero.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -794,3 +794,168 @@ button:hover { background: #c73650; }
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ===== Loading spinner ===== */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid var(--muted, #a59a8a);
|
||||
border-top-color: var(--accent, #e2822b);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Loading overlay for panels */
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: var(--muted, #a59a8a);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading-overlay .spinner {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* ===== Toast notifications ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #fff;
|
||||
z-index: 1000;
|
||||
animation: toastIn 0.3s ease;
|
||||
max-width: 90vw;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: #4a7fb0;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #4f9d69;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(1rem); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* ===== Button loading state ===== */
|
||||
.btn[disabled] {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-loading .spinner {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
/* ===== Error page shared ===== */
|
||||
.error-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent, #e2822b);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text, #eadfce);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
color: var(--muted, #a59a8a);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-actions a,
|
||||
.error-actions button {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.error-actions a:hover,
|
||||
.error-actions button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
background: var(--accent, #e2822b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
color: var(--accent, #e2822b);
|
||||
border: 1px solid var(--accent, #e2822b);
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
color: var(--accent, #e2822b);
|
||||
border: 1px solid var(--accent, #e2822b);
|
||||
}
|
||||
|
||||
.btn-retry:hover {
|
||||
background: var(--accent, #e2822b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/game.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -815,21 +815,6 @@ function startExpeditionSync() {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ===== Toast notifications =====
|
||||
function showToast(message, type = 'info') {
|
||||
// Remove existing toasts
|
||||
document.querySelectorAll('.toast').forEach((t) => t.remove());
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadCharacter();
|
||||
|
||||
@@ -272,17 +272,6 @@ function switchTab(tab) {
|
||||
document.getElementById('sell-tab').style.display = tab === 'sell' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ─── Toast notifications ─────────────────────────────────────────────────
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
document.querySelectorAll('.toast').forEach((t) => t.remove());
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Toast notification system — shared across all pages.
|
||||
// Usage: showToast('message', 'error' | 'info' | 'success');
|
||||
// Auto-dismisses after 4 seconds. Multiple toasts stack vertically.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const TOAST_DURATION = 4000;
|
||||
const MAX_VISIBLE = 3;
|
||||
const STACK_GAP = 8; // px between stacked toasts
|
||||
|
||||
/**
|
||||
* Show a toast notification.
|
||||
* @param {string} message - Toast text content.
|
||||
* @param {'error'|'info'|'success'} [type='info'] - Visual style.
|
||||
*/
|
||||
window.showToast = function showToast(message, type) {
|
||||
if (typeof message !== 'string') return;
|
||||
type = type || 'info';
|
||||
|
||||
// Remove old toasts of the same type to avoid clutter
|
||||
document.querySelectorAll('.toast-' + type).forEach(function (t) {
|
||||
t.remove();
|
||||
});
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.textContent = message;
|
||||
|
||||
// Position: stack from bottom
|
||||
var existing = document.querySelectorAll('.toast');
|
||||
var offset = existing.length * STACK_GAP;
|
||||
toast.style.bottom = (1.5 + offset / 16) + 'rem';
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(function () {
|
||||
if (toast.parentNode) toast.remove();
|
||||
}, TOAST_DURATION);
|
||||
};
|
||||
})();
|
||||
@@ -20,6 +20,7 @@
|
||||
</form>
|
||||
<p class="redirect">Don't have an account? <a href="/register.html">Register</a></p>
|
||||
</div>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/login.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/profile.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
</form>
|
||||
<p class="redirect">Already have an account? <a href="/login.html">Login</a></p>
|
||||
</div>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/register.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/shop.js"></script>
|
||||
</body>
|
||||
|
||||
+22
-1
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const path = require('path');
|
||||
const { createSessionMiddleware, requireAuth } = require('./middleware/session');
|
||||
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
|
||||
const { routes: characterRoutes } = require('./routes/character');
|
||||
@@ -91,7 +92,24 @@ function createApp() {
|
||||
// Hero profile route (public, no session required)
|
||||
app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile);
|
||||
|
||||
// Central error handler
|
||||
// Catch-all: serve 404 page for unknown paths (HTML requests only).
|
||||
// API paths still return JSON errors via the central handler.
|
||||
// Placed before static files so it intercepts non-file paths.
|
||||
const PUBLIC_DIR = path.resolve(__dirname, '../public');
|
||||
app.use((req, res, next) => {
|
||||
// If a route already set headers, let it through (e.g., static file not found)
|
||||
if (res.headersSent) return next();
|
||||
if (req.accepts('html')) {
|
||||
return res.status(404).sendFile(path.join(PUBLIC_DIR, '404.html'));
|
||||
}
|
||||
return res.status(404).json({
|
||||
ok: false,
|
||||
error: { code: 'NOT_FOUND', message: 'Resource not found.' },
|
||||
});
|
||||
});
|
||||
|
||||
// Static files (after catch-all for API paths, but static will still serve HTML pages)
|
||||
app.use(express.static('public'));
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Log stack traces server-side only
|
||||
if (err.stack) {
|
||||
@@ -120,6 +138,9 @@ function createApp() {
|
||||
}
|
||||
|
||||
// Generic error
|
||||
if (req.accepts('html')) {
|
||||
return res.status(500).sendFile(path.join(PUBLIC_DIR, '500.html'));
|
||||
}
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { setup, teardown, cleanup } = require('../helpers');
|
||||
|
||||
test('GET /nonexistent → serves 404.html for HTML requests', async () => {
|
||||
const base = await setup();
|
||||
try {
|
||||
const res = await fetch(base + '/nonexistent-page', {
|
||||
headers: { Accept: 'text/html' },
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
const html = await res.text();
|
||||
assert.ok(html.includes('404'), '404 page should contain "404"');
|
||||
assert.ok(html.includes('Page Not Found'), '404 page should have title');
|
||||
assert.ok(html.includes('Emberfall'), '404 page should be branded');
|
||||
} finally {
|
||||
await teardown();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /nonexistent → JSON error for API requests', async () => {
|
||||
const base = await setup();
|
||||
try {
|
||||
const res = await fetch(base + '/api/nonexistent', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
const data = await res.json();
|
||||
assert.equal(data.ok, false);
|
||||
assert.equal(data.error.code, 'NOT_FOUND');
|
||||
assert.ok(typeof data.error.message === 'string');
|
||||
} finally {
|
||||
await teardown();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /api/heroes/nonexistent → 404.html (public route, unknown hero)', async () => {
|
||||
const base = await setup();
|
||||
try {
|
||||
const res = await fetch(base + '/api/heroes/doesnotexist', {
|
||||
headers: { Accept: 'text/html' },
|
||||
});
|
||||
// Public route exists, so it returns JSON 404 (not the page 404)
|
||||
assert.equal(res.status, 404);
|
||||
const data = await res.json();
|
||||
assert.equal(data.ok, false);
|
||||
} finally {
|
||||
await teardown();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user