initial
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment/local config
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# OS/editor noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Runtime data/cache
|
||||||
|
backend/data/catalog.db
|
||||||
|
backend/data/*.db
|
||||||
|
backend/data/*.db-*
|
||||||
42
CHANGELOG.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-03-25
|
||||||
|
- Created initial frontend-only React + TypeScript project scaffold with Vite.
|
||||||
|
- Added typed Marathon API client for fetching/searching items from `/api/items/all`.
|
||||||
|
- Implemented initial app features:
|
||||||
|
- Item search by name.
|
||||||
|
- Top 5 relevant search results with icons.
|
||||||
|
- Click-to-add items into to-do list.
|
||||||
|
- Quantity editing per item.
|
||||||
|
- Completed state toggle.
|
||||||
|
- Item deletion.
|
||||||
|
- Added simple, minimal UI styling.
|
||||||
|
- Added `README.md` documentation and local setup instructions.
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-03-25
|
||||||
|
- Fetched faction upgrades data from `https://marathondb.gg/js/data/faction-upgrades.js` and added it to app data sources.
|
||||||
|
- Extended search results with faction upgrades that include salvage requirements in their levels.
|
||||||
|
- Added upgrade click behavior: clicking an upgrade adds all mapped salvage items to the to-do list and increases quantity when an item already exists.
|
||||||
|
- Added image URL fallback candidates for search/list icons with extension order ending in `.webp`.
|
||||||
|
|
||||||
|
## 0.3.0 - 2026-03-25
|
||||||
|
- Added a local proxy server (`proxy/server.js`) using Node + SQLite (`proxy/catalog.db`).
|
||||||
|
- Proxy now fetches Marathon items and faction upgrades from source URLs, builds a combined searchable catalog, and serves it at `GET /api/catalog`.
|
||||||
|
- Added automatic catalog refresh every 24 hours and a manual refresh endpoint `POST /api/catalog/refresh`.
|
||||||
|
- Frontend data loading now uses `/api/catalog` instead of hitting external source endpoints directly.
|
||||||
|
- Added Vite dev proxy configuration for `/api` and `/health` to `http://localhost:8787`.
|
||||||
|
- Added a right-side faction upgrade tag in search results, using faction color and `<faction name> upgrade` label text.
|
||||||
|
- Added temporary faction-color highlight animation on affected to-do items when clicking an upgrade search result.
|
||||||
|
|
||||||
|
## 0.4.0 - 2026-03-25
|
||||||
|
- Reorganized repository into explicit `frontend/` and `backend/` directories.
|
||||||
|
- Moved React app files to `frontend/` and proxy API to `backend/server.js`.
|
||||||
|
- Moved SQLite catalog storage path to `backend/data/catalog.db`.
|
||||||
|
- Added `backend/references/` for source reference files (`api.js`, faction upgrades snapshots).
|
||||||
|
- Updated scripts/config/docs for the new structure (`dev:frontend`, `dev:backend`, Vite root set to `frontend`).
|
||||||
|
- Added `dev:all` script to start backend and frontend together with one command.
|
||||||
|
- Replaced inline `dev:all` command with `scripts/dev-all.mjs` for reliable Windows process spawning.
|
||||||
|
- Upgrade search results now use faction assets from `backend/data/faction-assets/*.png` via `/api/faction-assets/:file`.
|
||||||
|
- Upgrade search result rows now have border color based on faction color.
|
||||||
|
- Upgrade search results now expose per-level salvage and require clicking a level button (`L1`, `L2`, ...) to add items.
|
||||||
|
- Faction-color border is now applied around the upgrade icon frame (not the whole search row).
|
||||||
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# marathon.todo
|
||||||
|
|
||||||
|
A frontend-only React + TypeScript app for planning what to loot (or do) in raid in Bungie's Marathon.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Search for Marathon items by name.
|
||||||
|
- Show up to 5 relevant results with item icons.
|
||||||
|
- Search also includes faction upgrades with salvage requirements.
|
||||||
|
- Add an item to a to-do list by clicking a search result.
|
||||||
|
- Clicking an upgrade search result adds all mapped salvage materials to the to-do list.
|
||||||
|
- Set quantity per to-do entry.
|
||||||
|
- Mark entries completed.
|
||||||
|
- Delete entries.
|
||||||
|
- Persist to-do entries in browser `localStorage`.
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
- Frontend fetches a local proxy endpoint: `/api/catalog`
|
||||||
|
- Proxy source data:
|
||||||
|
- Items: `https://items.marathondb.gg/api/items`
|
||||||
|
- Faction upgrades: `https://marathondb.gg/js/data/faction-upgrades.js`
|
||||||
|
- Proxy stores processed catalog in SQLite (`backend/data/catalog.db`) and refreshes every 24h.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
- `frontend/`: Vite + React app (`frontend/src`, `frontend/index.html`)
|
||||||
|
- `backend/`: local proxy API (`backend/server.js`) and SQLite data (`backend/data/`)
|
||||||
|
- `backend/references/`: downloaded reference source files used during development
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Vite
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. Start backend + frontend together:
|
||||||
|
```bash
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
3. Or run separately if preferred:
|
||||||
|
```bash
|
||||||
|
npm run dev:backend
|
||||||
|
npm run dev:frontend
|
||||||
|
```
|
||||||
|
4. Build production assets:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
BIN
backend/data/faction-assets/arachne.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
backend/data/faction-assets/cyberacme.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
backend/data/faction-assets/mida.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
backend/data/faction-assets/nucaloric.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
backend/data/faction-assets/sekiguchi.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
backend/data/faction-assets/traxus.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
backend/data/fonts/Ki-Bold.otf
Normal file
BIN
backend/data/fonts/Ki-Regular.otf
Normal file
745
backend/references/api.js
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
// Marathon API Client
|
||||||
|
// Provides API access for all database pages
|
||||||
|
|
||||||
|
// ─── Session Cache ────────────────────────────────────────────────
|
||||||
|
// Thin sessionStorage layer — caches GET responses with a 5-min TTL
|
||||||
|
// so repeat visits within the same tab session avoid redundant fetches.
|
||||||
|
const ApiCache = (function () {
|
||||||
|
const TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const PREFIX = 'mdb_';
|
||||||
|
|
||||||
|
function _key(url) { return PREFIX + url; }
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(url) {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(_key(url));
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw);
|
||||||
|
if (Date.now() - entry.ts > TTL) {
|
||||||
|
sessionStorage.removeItem(_key(url));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
} catch { return null; }
|
||||||
|
},
|
||||||
|
set(url, data) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(_key(url), JSON.stringify({ ts: Date.now(), data }));
|
||||||
|
} catch { /* quota exceeded — silently skip */ }
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
try {
|
||||||
|
Object.keys(sessionStorage)
|
||||||
|
.filter(k => k.startsWith(PREFIX))
|
||||||
|
.forEach(k => sessionStorage.removeItem(k));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const DISPLAY_NAME_KEYS = new Set([
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'weapon_name',
|
||||||
|
'runner_name',
|
||||||
|
'collection_name',
|
||||||
|
'faction_name'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeDisplayName(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return value;
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(token => token
|
||||||
|
.split(/([\-/'’])/)
|
||||||
|
.map(part => {
|
||||||
|
if (!part || /[\-/'’]/.test(part)) return part;
|
||||||
|
if (!/[A-Za-z]/.test(part)) return part;
|
||||||
|
if (/^[ivxlcdm]+$/i.test(part) && part.length <= 6) return part.toUpperCase();
|
||||||
|
if (/^[A-Z0-9]+$/.test(part) && part.length <= 5) return part;
|
||||||
|
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
||||||
|
})
|
||||||
|
.join(''))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayNamesInPayload(payload) {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload.map(normalizeDisplayNamesInPayload);
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = {};
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (DISPLAY_NAME_KEYS.has(key) && typeof value === 'string') {
|
||||||
|
normalized[key] = normalizeDisplayName(value);
|
||||||
|
} else {
|
||||||
|
normalized[key] = normalizeDisplayNamesInPayload(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.MarathonNameUtils = window.MarathonNameUtils || {};
|
||||||
|
window.MarathonNameUtils.normalizeDisplayName = normalizeDisplayName;
|
||||||
|
window.MarathonNameUtils.normalizeDisplayNamesInPayload = normalizeDisplayNamesInPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarathonAPI = (function() {
|
||||||
|
const API_BASE = 'https://helpbot.marathondb.gg';
|
||||||
|
const CORES_API_BASE = 'https://cores.marathondb.gg';
|
||||||
|
const IMPLANTS_API_BASE = 'https://implants.marathondb.gg';
|
||||||
|
const MODS_API_BASE = 'https://mods.marathondb.gg';
|
||||||
|
const CONTRACTS_API_BASE = 'https://marathon-contracts-api.heymarathondb.workers.dev';
|
||||||
|
|
||||||
|
// Generic fetch wrapper (with session cache for GETs)
|
||||||
|
async function fetchAPI(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
const { headers: extraHeaders, ...rest } = options;
|
||||||
|
const method = (rest.method || 'GET').toUpperCase();
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
// Cache hit — return immediately for safe methods
|
||||||
|
if (method === 'GET') {
|
||||||
|
const cached = ApiCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsContentType = method !== 'GET' && method !== 'HEAD';
|
||||||
|
const headers = {
|
||||||
|
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(extraHeaders || {}),
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`API Error: ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && data) ApiCache.set(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) throw error; // Re-throw API errors with body attached
|
||||||
|
console.error('API fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cores-specific fetch wrapper (uses cores.marathondb.gg exclusively)
|
||||||
|
async function fetchCoresAPI(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
const { headers: extraHeaders, ...rest } = options;
|
||||||
|
const method = (rest.method || 'GET').toUpperCase();
|
||||||
|
const url = `${CORES_API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
const cached = ApiCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsContentType = method !== 'GET' && method !== 'HEAD';
|
||||||
|
const headers = {
|
||||||
|
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(extraHeaders || {}),
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`Cores API Error: ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && data) ApiCache.set(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) throw error;
|
||||||
|
console.error('Cores API fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implants-specific fetch wrapper (uses implants.marathondb.gg exclusively)
|
||||||
|
async function fetchImplantsAPI(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
const { headers: extraHeaders, ...rest } = options;
|
||||||
|
const method = (rest.method || 'GET').toUpperCase();
|
||||||
|
const url = `${IMPLANTS_API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
const cached = ApiCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsContentType = method !== 'GET' && method !== 'HEAD';
|
||||||
|
const headers = {
|
||||||
|
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(extraHeaders || {}),
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`Implants API Error: ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && data) ApiCache.set(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) throw error;
|
||||||
|
console.error('Implants API fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mods-specific fetch wrapper (uses mods.marathondb.gg exclusively)
|
||||||
|
async function fetchModsAPI(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
const { headers: extraHeaders, ...rest } = options;
|
||||||
|
const method = (rest.method || 'GET').toUpperCase();
|
||||||
|
const url = `${MODS_API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
const cached = ApiCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsContentType = method !== 'GET' && method !== 'HEAD';
|
||||||
|
const headers = {
|
||||||
|
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(extraHeaders || {}),
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`Mods API Error: ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && data) ApiCache.set(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) throw error;
|
||||||
|
console.error('Mods API fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contracts-specific fetch wrapper (uses marathon-contracts-api.heymarathondb.workers.dev)
|
||||||
|
async function fetchContractsAPI(endpoint) {
|
||||||
|
try {
|
||||||
|
const url = `${CONTRACTS_API_BASE}${endpoint}`;
|
||||||
|
const cached = ApiCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`Contracts API Error: ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) ApiCache.set(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) throw error;
|
||||||
|
console.error('Contracts API fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ============ API BASE ============
|
||||||
|
getApiBase: function() {
|
||||||
|
return API_BASE;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ GENERIC GET ============
|
||||||
|
get: async function(endpoint) {
|
||||||
|
const result = await fetchAPI(`/api${endpoint}`);
|
||||||
|
// API already returns { success, data } format
|
||||||
|
if (result && result.success !== undefined) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Wrap raw data in standard format
|
||||||
|
return { success: !!result, data: result };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ WEAPONS ============
|
||||||
|
getWeapons: async function(category = null) {
|
||||||
|
const endpoint = category ? `/api/weapons?category=${category}` : '/api/weapons';
|
||||||
|
return await fetchAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWeaponBySlug: async function(slug) {
|
||||||
|
return await fetchAPI(`/api/weapons/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWeaponHistory: async function(slug) {
|
||||||
|
return await fetchAPI(`/api/weapons/${slug}/history`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ CATEGORIES ============
|
||||||
|
getCategories: async function() {
|
||||||
|
return await fetchAPI('/api/categories');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ RUNNERS ============
|
||||||
|
getRunners: async function() {
|
||||||
|
return await fetchAPI('/api/runners');
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunnerBySlug: async function(slug) {
|
||||||
|
return await fetchAPI(`/api/runners/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunnerHistory: async function(slug) {
|
||||||
|
return await fetchAPI(`/api/runners/${slug}/history`);
|
||||||
|
},
|
||||||
|
|
||||||
|
compareRunners: async function(season = null) {
|
||||||
|
const endpoint = season ? `/api/runners/compare?season=${season}` : '/api/runners/compare';
|
||||||
|
return await fetchAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ ITEMS ============
|
||||||
|
// Unified endpoint - returns all items from all tables
|
||||||
|
getAllItems: async function() {
|
||||||
|
return await fetchAPI('/api/items/all');
|
||||||
|
},
|
||||||
|
|
||||||
|
getItems: async function(type = null, rarity = null) {
|
||||||
|
let endpoint = '/api/items';
|
||||||
|
const params = [];
|
||||||
|
if (type) params.push(`type=${type}`);
|
||||||
|
if (rarity) params.push(`rarity=${rarity}`);
|
||||||
|
if (params.length) endpoint += '?' + params.join('&');
|
||||||
|
return await fetchAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getItemsByType: async function(type) {
|
||||||
|
return await fetchAPI(`/api/items/types/${type}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getItemBySlug: async function(slug) {
|
||||||
|
return await fetchAPI(`/api/items/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get available item types for filter dropdowns (API v2.0.0)
|
||||||
|
getItemTypes: async function() {
|
||||||
|
return await fetchAPI('/api/items/types');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ MODS ============
|
||||||
|
getMods: async function() {
|
||||||
|
return await fetchModsAPI('/api/mods');
|
||||||
|
},
|
||||||
|
|
||||||
|
getModBySlug: async function(slug) {
|
||||||
|
return await fetchModsAPI(`/api/mods/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getModsForWeapon: async function(weaponSlug) {
|
||||||
|
return await fetchModsAPI(`/api/weapons/${encodeURIComponent(weaponSlug)}/mods`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getModSlotsForWeapon: async function(weaponSlug) {
|
||||||
|
return await fetchModsAPI(`/api/weapons/${encodeURIComponent(weaponSlug)}/slots`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ SEASONS ============
|
||||||
|
getSeasons: async function() {
|
||||||
|
return await fetchAPI('/api/seasons');
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentSeason: async function() {
|
||||||
|
return await fetchAPI('/api/seasons/current');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ LOADOUTS ============
|
||||||
|
createLoadout: async function(loadout) {
|
||||||
|
return await fetchAPI('/api/loadouts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(loadout),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getLoadout: async function(shareCode) {
|
||||||
|
return await fetchAPI(`/api/loadouts/${shareCode}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getLoadouts: async function(params = {}) {
|
||||||
|
const qs = Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
||||||
|
return await fetchAPI(`/api/loadouts${qs ? '?' + qs : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLoadout: async function(shareCode, adminKey) {
|
||||||
|
return await fetchAPI(`/api/loadouts/${shareCode}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-Admin-Key': adminKey },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ STAT RANGES ============
|
||||||
|
getWeaponStatRanges: async function() {
|
||||||
|
return await fetchAPI('/api/weapons/stat-ranges');
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunnerStatRanges: async function() {
|
||||||
|
return await fetchAPI('/api/runners/stat-ranges');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ ASSET URLs ============
|
||||||
|
getWeaponIconUrl: function(iconPath, resolution = 'low') {
|
||||||
|
if (!iconPath) return `${API_BASE}/assets/weapons/placeholder.png`;
|
||||||
|
const filename = iconPath.split('/').pop();
|
||||||
|
return `${API_BASE}/assets/weapons/${encodeURIComponent(filename)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get weapon icon with resolution option ('low' = 180x135, 'high' = 800x600)
|
||||||
|
getWeaponIconUrlBySlug: function(slug, resolution = 'low') {
|
||||||
|
if (!slug) return `${API_BASE}/assets/weapons/placeholder.png`;
|
||||||
|
const suffix = resolution === 'high' ? '800x600' : '180x135';
|
||||||
|
return `${API_BASE}/assets/weapons/${slug}-${suffix}.png`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunnerIconUrl: function(iconPath) {
|
||||||
|
if (!iconPath) return `${API_BASE}/assets/runners/placeholder.png`;
|
||||||
|
// If it's already a full path from API (e.g., "assets/runners/thief-300x460.png")
|
||||||
|
if (iconPath.startsWith('assets/')) {
|
||||||
|
return `${API_BASE}/${iconPath}`;
|
||||||
|
}
|
||||||
|
// If slug is passed instead of full path, construct the URL
|
||||||
|
const slug = iconPath.toLowerCase().replace(/\.png$/, '');
|
||||||
|
return `${API_BASE}/assets/runners/${slug}-300x460.png`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get runner icon with resolution option ('low' = 150x230, 'high' = 300x460)
|
||||||
|
getRunnerIconUrlBySlug: function(slug, resolution = 'high') {
|
||||||
|
if (!slug) return `${API_BASE}/assets/runners/placeholder.png`;
|
||||||
|
const suffix = resolution === 'low' ? '150x230' : '300x460';
|
||||||
|
return `${API_BASE}/assets/runners/${slug}-${suffix}.png`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getItemIconUrl: function(iconPath) {
|
||||||
|
if (!iconPath) return `${API_BASE}/assets/items/placeholder.png`;
|
||||||
|
// If it's already a full path from API (e.g., "assets/items/consumables/patch-kit-64x64.png")
|
||||||
|
if (iconPath.startsWith('assets/')) {
|
||||||
|
return `${API_BASE}/${iconPath}`;
|
||||||
|
}
|
||||||
|
// Fallback: assume it's just a filename or slug
|
||||||
|
return `${API_BASE}/assets/items/${encodeURIComponent(iconPath)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatIconUrl: function(statKey) {
|
||||||
|
// Map stat keys to icon filenames
|
||||||
|
const statIcons = {
|
||||||
|
'heat_capacity': 'heat-capacity.jpg',
|
||||||
|
'agility': 'agility.jpg',
|
||||||
|
'loot_speed': 'loot-speed.jpg',
|
||||||
|
'self_repair_speed': 'self-repair-speed.jpg',
|
||||||
|
'finisher_siphon': 'finisher-siphon.jpg',
|
||||||
|
'revive_speed': 'revive-speed.jpg',
|
||||||
|
'hardware': 'hardware.jpg',
|
||||||
|
'firewall': 'firewall.jpg'
|
||||||
|
};
|
||||||
|
const filename = statIcons[statKey] || 'placeholder.png';
|
||||||
|
return `/assets/icons/${filename}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ CONSUMABLES ============
|
||||||
|
getConsumables: async function(type = null, rarity = null) {
|
||||||
|
let endpoint = '/api/consumables';
|
||||||
|
const params = [];
|
||||||
|
if (type) params.push(`type=${type}`);
|
||||||
|
if (rarity) params.push(`rarity=${rarity}`);
|
||||||
|
if (params.length > 0) endpoint += `?${params.join('&')}`;
|
||||||
|
return await fetchAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ CORES ============
|
||||||
|
getCores: async function(runnerType = null, rarity = null, purchaseable = null, activeOnly = null) {
|
||||||
|
let endpoint = '/api/cores';
|
||||||
|
const params = [];
|
||||||
|
if (runnerType) params.push(`runner=${runnerType}`);
|
||||||
|
if (rarity) params.push(`rarity=${rarity}`);
|
||||||
|
if (purchaseable) params.push(`purchaseable=true`);
|
||||||
|
if (activeOnly !== null) params.push(`active=${activeOnly}`);
|
||||||
|
if (params.length > 0) endpoint += `?${params.join('&')}`;
|
||||||
|
return await fetchCoresAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCoresByRunner: async function(runnerType) {
|
||||||
|
return await fetchCoresAPI(`/api/cores/runner/${runnerType}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCoreBySlug: async function(slug) {
|
||||||
|
// Returns detailed core info with full balance history
|
||||||
|
return await fetchCoresAPI(`/api/cores/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCoreChangelog: async function() {
|
||||||
|
// Chronological changelog of all balance changes
|
||||||
|
return await fetchCoresAPI('/api/cores/changelog');
|
||||||
|
},
|
||||||
|
|
||||||
|
getCoreIconUrl: function(iconPath) {
|
||||||
|
if (!iconPath) return `${CORES_API_BASE}/assets/items/cores/placeholder.png`;
|
||||||
|
// If it's already a full URL from the API response
|
||||||
|
if (iconPath.startsWith('http')) return iconPath;
|
||||||
|
// If it's a relative path from API
|
||||||
|
if (iconPath.startsWith('assets/')) {
|
||||||
|
return `${CORES_API_BASE}/${iconPath}`;
|
||||||
|
}
|
||||||
|
// Fallback: assume it's a slug and construct the path
|
||||||
|
return `${CORES_API_BASE}/assets/items/cores/${encodeURIComponent(iconPath)}-72x72.png`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ IMPLANTS ============
|
||||||
|
getImplants: async function(slot = null, rarity = null, page = null, limit = null) {
|
||||||
|
let endpoint = '/api/implants';
|
||||||
|
const params = [];
|
||||||
|
if (slot) params.push(`slot=${slot}`);
|
||||||
|
if (rarity) params.push(`rarity=${rarity}`);
|
||||||
|
if (page) params.push(`page=${page}`);
|
||||||
|
if (limit) params.push(`limit=${limit}`);
|
||||||
|
if (params.length > 0) endpoint += `?${params.join('&')}`;
|
||||||
|
return await fetchImplantsAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getImplantBySlug: async function(slug) {
|
||||||
|
return await fetchImplantsAPI(`/api/implants/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getImplantSlots: async function() {
|
||||||
|
return await fetchImplantsAPI('/api/implants/slots');
|
||||||
|
},
|
||||||
|
|
||||||
|
getImplantsBySlot: async function(slot) {
|
||||||
|
return await fetchImplantsAPI(`/api/implants/slot/${slot}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTraits: async function() {
|
||||||
|
return await fetchImplantsAPI('/api/traits');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ FACTIONS (Contracts API) ============
|
||||||
|
getFactions: async function() {
|
||||||
|
return await fetchContractsAPI('/api/factions');
|
||||||
|
},
|
||||||
|
|
||||||
|
getFactionBySlug: async function(slug) {
|
||||||
|
return await fetchContractsAPI(`/api/factions/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFactionContracts: async function(slug, type = null) {
|
||||||
|
let endpoint = `/api/factions/${slug}/contracts`;
|
||||||
|
const params = [];
|
||||||
|
if (type) params.push(`type=${type}`);
|
||||||
|
if (params.length > 0) endpoint += `?${params.join('&')}`;
|
||||||
|
return await fetchContractsAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFactionUpgrades: async function(slug, category = null, tier = null) {
|
||||||
|
let endpoint = `/api/factions/${slug}/upgrades`;
|
||||||
|
const params = [];
|
||||||
|
if (category) params.push(`category=${category}`);
|
||||||
|
if (tier) params.push(`tier=${tier}`);
|
||||||
|
if (params.length > 0) endpoint += `?${params.join('&')}`;
|
||||||
|
return await fetchContractsAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFactionReputation: async function(slug) {
|
||||||
|
return await fetchContractsAPI(`/api/factions/${slug}/reputation`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ CONTRACTS (Contracts API) ============
|
||||||
|
getContracts: async function(options = {}) {
|
||||||
|
let endpoint = '/api/contracts';
|
||||||
|
const params = [];
|
||||||
|
if (options.type) params.push(`type=${options.type}`);
|
||||||
|
if (options.faction) params.push(`faction=${options.faction}`);
|
||||||
|
if (options.difficulty) params.push(`difficulty=${options.difficulty}`);
|
||||||
|
if (options.map) params.push(`map=${options.map}`);
|
||||||
|
if (options.scope) params.push(`scope=${options.scope}`);
|
||||||
|
if (options.tag) params.push(`tag=${options.tag}`);
|
||||||
|
if (options.chain) params.push(`chain=${options.chain}`);
|
||||||
|
if (options.season) params.push(`season=${options.season}`);
|
||||||
|
if (options.active !== undefined) params.push(`active=${options.active}`);
|
||||||
|
if (params.length > 0) endpoint += `?${params.join('&')}`;
|
||||||
|
return await fetchContractsAPI(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
getContractBySlug: async function(slug) {
|
||||||
|
return await fetchContractsAPI(`/api/contracts/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getContractRotation: async function() {
|
||||||
|
return await fetchContractsAPI('/api/contracts/rotation');
|
||||||
|
},
|
||||||
|
|
||||||
|
getContractTags: async function() {
|
||||||
|
return await fetchContractsAPI('/api/contract-tags');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ DATABASE STATS ============
|
||||||
|
// New unified stats endpoint (API v2.0.0)
|
||||||
|
getStats: async function() {
|
||||||
|
return await fetchAPI('/api/stats');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy method - now uses the new /api/stats endpoint
|
||||||
|
getDbStats: async function() {
|
||||||
|
try {
|
||||||
|
const stats = await this.getStats();
|
||||||
|
if (stats) {
|
||||||
|
return {
|
||||||
|
weapons: stats.weapons?.total || 0,
|
||||||
|
runners: stats.runners?.total || 0,
|
||||||
|
items: stats.items?.total || 0,
|
||||||
|
mods: stats.mods?.total || 0,
|
||||||
|
cores: stats.cores?.total || 0,
|
||||||
|
factions: stats.factions?.total || 0,
|
||||||
|
cosmetics: stats.cosmetics?.total || 0,
|
||||||
|
maps: stats.maps?.total || 0,
|
||||||
|
implants: stats.implants?.total || 0,
|
||||||
|
contracts: stats.contracts?.total || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { weapons: 0, runners: 0, items: 0, mods: 0 };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error);
|
||||||
|
return { weapons: 0, runners: 0, items: 0, mods: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Weapons API Client — https://weapons.marathondb.gg
|
||||||
|
const WeaponsAPI = (function() {
|
||||||
|
const BASE = 'https://weapons.marathondb.gg';
|
||||||
|
|
||||||
|
async function fetchAPI(endpoint) {
|
||||||
|
try {
|
||||||
|
const url = `${BASE}${endpoint}`;
|
||||||
|
const cached = ApiCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`Weapons API Error: ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (data) ApiCache.set(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) throw error;
|
||||||
|
console.error('WeaponsAPI fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the current-season stats row and flatten into weapon object.
|
||||||
|
// Prefer is_current === true; fall back to the row with the highest season_id (latest).
|
||||||
|
function normalizeWeapon(weapon) {
|
||||||
|
if (!weapon) return weapon;
|
||||||
|
if (!Array.isArray(weapon.stats) || weapon.stats.length === 0) return weapon;
|
||||||
|
const current = weapon.stats.reduce((a, b) => (b.season_id > a.season_id ? b : a));
|
||||||
|
return { ...weapon, stats: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getWeapons: async function(category = null) {
|
||||||
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||||
|
return await fetchAPI(`/api/weapons${qs}`);
|
||||||
|
},
|
||||||
|
getCategories: async function() {
|
||||||
|
return await fetchAPI('/api/weapons/categories');
|
||||||
|
},
|
||||||
|
getWeaponBySlug: async function(slug) {
|
||||||
|
const result = await fetchAPI(`/api/weapons/${encodeURIComponent(slug)}`);
|
||||||
|
if (result?.data) result.data = normalizeWeapon(result.data);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
getStatRanges: async function() {
|
||||||
|
return await fetchAPI('/api/weapons/stat-ranges');
|
||||||
|
},
|
||||||
|
normalizeWeapon,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Twitch API Client (separate endpoint — 2-min cache for live data)
|
||||||
|
const TwitchAPI = (function() {
|
||||||
|
const API_BASE = 'https://twitch.rnk.gg/api/v2/categories/407314011';
|
||||||
|
const TWITCH_TTL = 2 * 60 * 1000;
|
||||||
|
const _twitchCache = {};
|
||||||
|
|
||||||
|
async function fetchAPI(endpoint) {
|
||||||
|
try {
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
const hit = _twitchCache[url];
|
||||||
|
if (hit && Date.now() - hit.ts < TWITCH_TTL) return hit.data;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`Twitch API Error: ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
_twitchCache[url] = { ts: Date.now(), data };
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Twitch API error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentStats: async function() {
|
||||||
|
return await fetchAPI('/current-v2');
|
||||||
|
},
|
||||||
|
|
||||||
|
getHistory: async function(range = '7d') {
|
||||||
|
return await fetchAPI(`/history-v2?range=${range}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
1479
backend/references/faction-upgrades.js
Normal file
1479
backend/references/faction-upgrades.live.js
Normal file
592
backend/server.js
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8787);
|
||||||
|
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const DB_PATH = resolve('backend', 'data', 'catalog.db');
|
||||||
|
const FACTION_ASSETS_DIR = resolve('backend', 'data', 'faction-assets');
|
||||||
|
const ITEMS_URL = 'https://items.marathondb.gg/api/items';
|
||||||
|
const UPGRADES_URL = 'https://marathondb.gg/js/data/faction-upgrades.js';
|
||||||
|
|
||||||
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
const db = new DatabaseSync(DB_PATH);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS popularity_stats (
|
||||||
|
entry_type TEXT NOT NULL CHECK (entry_type IN ('item', 'upgrade')),
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
add_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (entry_type, slug)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const upsertCacheStatement = db.prepare(`
|
||||||
|
INSERT INTO catalog_cache (id, payload, updated_at_ms)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at_ms = excluded.updated_at_ms
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectCacheStatement = db.prepare('SELECT payload, updated_at_ms FROM catalog_cache WHERE id = 1');
|
||||||
|
const incrementPopularityStatement = db.prepare(`
|
||||||
|
INSERT INTO popularity_stats (entry_type, slug, add_count)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON CONFLICT(entry_type, slug) DO UPDATE SET add_count = popularity_stats.add_count + 1
|
||||||
|
`);
|
||||||
|
const selectPopularStatement = db.prepare(`
|
||||||
|
SELECT entry_type, slug, add_count
|
||||||
|
FROM popularity_stats
|
||||||
|
ORDER BY add_count DESC, slug ASC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '#3a4f77';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || '#3a4f77';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getName(raw) {
|
||||||
|
const candidates = [raw.name, raw.display_name, raw.item_name, raw.title];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const name = normalizeName(candidate);
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlug(raw) {
|
||||||
|
const candidates = [raw.slug, raw.id, raw.item_id, raw.uuid, raw.name];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `item-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconPath(raw) {
|
||||||
|
const candidates = [raw.icon, raw.icon_path, raw.image, raw.image_url];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarity(raw) {
|
||||||
|
const candidates = [raw.rarity, raw.rarity_name, raw.item_rarity, raw.quality, raw.tier];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemIconUrl(iconPath, slug) {
|
||||||
|
const itemImageBase = 'https://items.marathondb.gg/images/items';
|
||||||
|
const apiBase = 'https://helpbot.marathondb.gg';
|
||||||
|
|
||||||
|
if (!iconPath) {
|
||||||
|
return `${itemImageBase}/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconPath.startsWith('http://') || iconPath.startsWith('https://')) {
|
||||||
|
return iconPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconPath.startsWith('assets/')) {
|
||||||
|
return `${apiBase}/${iconPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${itemImageBase}/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRawItems(payload) {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload.filter((row) => typeof row === 'object' && row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataArray = asArray(payload.data);
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
return dataArray.filter((row) => typeof row === 'object' && row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataAsObject = payload.data;
|
||||||
|
if (dataAsObject && typeof dataAsObject === 'object' && Array.isArray(dataAsObject.items)) {
|
||||||
|
return dataAsObject.items.filter((row) => typeof row === 'object' && row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFactionUpgradesFromScript(source) {
|
||||||
|
const context = { window: {} };
|
||||||
|
vm.createContext(context);
|
||||||
|
|
||||||
|
const script = new vm.Script(`\n${source}\n;globalThis.__factionUpgrades = typeof FACTION_UPGRADES !== 'undefined' ? FACTION_UPGRADES : window.FACTION_UPGRADES;\n`);
|
||||||
|
script.runInContext(context, { timeout: 5000 });
|
||||||
|
|
||||||
|
const parsed = context.__factionUpgrades;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new Error('Could not parse FACTION_UPGRADES from faction-upgrades.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFactionAssetUrl(factionKey) {
|
||||||
|
const normalizedKey = normalizeName(factionKey).toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||||
|
return `/api/faction-assets/${normalizedKey}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpgradeResults(factionUpgrades, itemsBySlug) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const [factionKey, factionValue] of Object.entries(factionUpgrades)) {
|
||||||
|
if (!factionValue || typeof factionValue !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const faction = factionValue;
|
||||||
|
const factionName = normalizeName(faction.name) || factionKey;
|
||||||
|
const factionColor = normalizeColor(faction.color);
|
||||||
|
const upgrades = asArray(faction.upgrades);
|
||||||
|
|
||||||
|
for (const upgradeEntry of upgrades) {
|
||||||
|
if (!upgradeEntry || typeof upgradeEntry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrade = upgradeEntry;
|
||||||
|
const upgradeName = normalizeName(upgrade.name);
|
||||||
|
if (!upgradeName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedLevels = [];
|
||||||
|
const levels = asArray(upgrade.levels);
|
||||||
|
|
||||||
|
for (const [levelIndex, levelEntry] of levels.entries()) {
|
||||||
|
if (!levelEntry || typeof levelEntry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelNumber = Math.max(1, Math.floor(toNumber(levelEntry.level)) || levelIndex + 1);
|
||||||
|
const salvageBySlug = new Map();
|
||||||
|
const salvage = asArray(levelEntry.salvage);
|
||||||
|
for (const salvageEntry of salvage) {
|
||||||
|
if (!salvageEntry || typeof salvageEntry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salvageSlug = normalizeName(salvageEntry.slug).toLowerCase();
|
||||||
|
const amount = Math.floor(toNumber(salvageEntry.amount));
|
||||||
|
if (!salvageSlug || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = salvageBySlug.get(salvageSlug) || 0;
|
||||||
|
salvageBySlug.set(salvageSlug, current + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedSalvage = Array.from(salvageBySlug.entries())
|
||||||
|
.map(([slug, amount]) => ({ slug, amount, item: itemsBySlug.get(slug) }))
|
||||||
|
.filter((entry) => Boolean(entry.item))
|
||||||
|
.map((entry) => ({
|
||||||
|
slug: entry.slug,
|
||||||
|
amount: entry.amount,
|
||||||
|
name: entry.item.name,
|
||||||
|
iconUrl: entry.item.iconUrl,
|
||||||
|
rarity: entry.item.rarity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mappedLevels.push({
|
||||||
|
level: levelNumber,
|
||||||
|
salvage: mappedSalvage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelsWithSalvage = mappedLevels.filter((entry) => entry.salvage.length > 0);
|
||||||
|
if (levelsWithSalvage.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedLevels.sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
|
const rawUpgradeSlug = normalizeName(upgrade.slug).toLowerCase().replace(/\s+/g, '-');
|
||||||
|
const fallbackSlug = `${factionKey}-${upgradeName.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
|
const upgradeSlug = rawUpgradeSlug || fallbackSlug;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: `upgrade-${upgradeSlug}`,
|
||||||
|
slug: upgradeSlug,
|
||||||
|
name: `${upgradeName} (${factionName})`,
|
||||||
|
factionName,
|
||||||
|
factionColor,
|
||||||
|
iconUrl: getFactionAssetUrl(factionKey),
|
||||||
|
levels: mappedLevels,
|
||||||
|
isUpgrade: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs) {
|
||||||
|
const rows = extractRawItems(itemsPayload);
|
||||||
|
|
||||||
|
const items = rows
|
||||||
|
.map((row) => {
|
||||||
|
const name = getName(row);
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = getSlug(row);
|
||||||
|
const iconPath = getIconPath(row);
|
||||||
|
const rarity = getRarity(row);
|
||||||
|
return {
|
||||||
|
id: slug,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
iconUrl: getItemIconUrl(iconPath, slug),
|
||||||
|
rarity,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const itemsBySlug = new Map(items.map((item) => [item.slug, item]));
|
||||||
|
const upgrades = buildUpgradeResults(factionUpgradesPayload, itemsBySlug);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: new Date(updatedAtMs).toISOString(),
|
||||||
|
items,
|
||||||
|
upgrades,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedCatalog() {
|
||||||
|
const row = selectCacheStatement.get();
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
payload: JSON.parse(row.payload),
|
||||||
|
updatedAtMs: row.updated_at_ms,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCatalog(catalog, updatedAtMs) {
|
||||||
|
upsertCacheStatement.run(JSON.stringify(catalog), updatedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'marathon-todo-proxy/1.0',
|
||||||
|
Accept: 'application/json,text/javascript,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed for ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'marathon-todo-proxy/1.0',
|
||||||
|
Accept: 'text/javascript,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed for ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCatalog() {
|
||||||
|
if (refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const [itemsPayload, upgradesScript] = await Promise.all([fetchJson(ITEMS_URL), fetchText(UPGRADES_URL)]);
|
||||||
|
const factionUpgradesPayload = parseFactionUpgradesFromScript(upgradesScript);
|
||||||
|
const updatedAtMs = Date.now();
|
||||||
|
const catalog = buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs);
|
||||||
|
|
||||||
|
writeCatalog(catalog, updatedAtMs);
|
||||||
|
return catalog;
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStale(updatedAtMs) {
|
||||||
|
return Date.now() - updatedAtMs >= REFRESH_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, statusCode, payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendBinary(response, statusCode, contentType, body) {
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Length': body.byteLength,
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestBody(request) {
|
||||||
|
return new Promise((resolveBody, rejectBody) => {
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
request.on('data', (chunk) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('end', () => {
|
||||||
|
resolveBody(Buffer.concat(chunks).toString('utf-8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', (error) => {
|
||||||
|
rejectBody(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request) {
|
||||||
|
const raw = await readRequestBody(request);
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON body');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||||
|
|
||||||
|
if (request.method === 'GET' && requestUrl.pathname === '/health') {
|
||||||
|
sendJson(response, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST' && requestUrl.pathname === '/api/catalog/refresh') {
|
||||||
|
try {
|
||||||
|
const catalog = await refreshCatalog();
|
||||||
|
sendJson(response, 200, catalog);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown refresh error' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && requestUrl.pathname.startsWith('/api/faction-assets/')) {
|
||||||
|
const filename = requestUrl.pathname.replace('/api/faction-assets/', '');
|
||||||
|
if (!/^[a-z0-9_-]+\.png$/i.test(filename)) {
|
||||||
|
sendJson(response, 400, { error: 'Invalid faction asset path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetPath = resolve(FACTION_ASSETS_DIR, filename);
|
||||||
|
if (!assetPath.startsWith(FACTION_ASSETS_DIR) || !existsSync(assetPath)) {
|
||||||
|
sendJson(response, 404, { error: 'Faction asset not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const asset = readFileSync(assetPath);
|
||||||
|
sendBinary(response, 200, 'image/png', asset);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Failed to read faction asset' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && requestUrl.pathname === '/api/catalog') {
|
||||||
|
const cached = readCachedCatalog();
|
||||||
|
if (cached) {
|
||||||
|
if (isStale(cached.updatedAtMs)) {
|
||||||
|
void refreshCatalog().catch((error) => {
|
||||||
|
console.error('[proxy] background refresh failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendJson(response, 200, cached.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const catalog = await refreshCatalog();
|
||||||
|
sendJson(response, 200, catalog);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown fetch error' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST' && requestUrl.pathname === '/api/popularity/track') {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const entryType = body?.type === 'upgrade' ? 'upgrade' : body?.type === 'item' ? 'item' : '';
|
||||||
|
const slug = normalizeName(body?.slug).toLowerCase();
|
||||||
|
|
||||||
|
if (!entryType || !slug) {
|
||||||
|
sendJson(response, 400, { error: 'Payload must include type ("item" or "upgrade") and slug.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementPopularityStatement.run(entryType, slug);
|
||||||
|
sendJson(response, 200, { ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 400, { error: error instanceof Error ? error.message : 'Invalid request' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && requestUrl.pathname === '/api/popularity') {
|
||||||
|
const limitParam = Number(requestUrl.searchParams.get('limit'));
|
||||||
|
const limit = Number.isFinite(limitParam) ? Math.min(Math.max(Math.floor(limitParam), 1), 20) : 5;
|
||||||
|
|
||||||
|
const cached = readCachedCatalog();
|
||||||
|
let catalog = cached?.payload;
|
||||||
|
|
||||||
|
if (!catalog) {
|
||||||
|
try {
|
||||||
|
catalog = await refreshCatalog();
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load catalog for popularity',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = selectPopularStatement.all(limit * 4);
|
||||||
|
const itemBySlug = new Map((Array.isArray(catalog.items) ? catalog.items : []).map((item) => [item.slug, item]));
|
||||||
|
const upgradeBySlug = new Map(
|
||||||
|
(Array.isArray(catalog.upgrades) ? catalog.upgrades : []).map((upgrade) => [upgrade.slug, upgrade]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const picks = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (picks.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = normalizeName(row.slug).toLowerCase();
|
||||||
|
if (!slug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.entry_type === 'item') {
|
||||||
|
const item = itemBySlug.get(slug);
|
||||||
|
if (item) {
|
||||||
|
picks.push(item);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.entry_type === 'upgrade') {
|
||||||
|
const upgrade = upgradeBySlug.get(slug);
|
||||||
|
if (upgrade) {
|
||||||
|
picks.push(upgrade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 200, { picks });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[proxy] listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
void refreshCatalog().catch((error) => {
|
||||||
|
console.error('[proxy] scheduled refresh failed:', error);
|
||||||
|
});
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
void refreshCatalog().catch((error) => {
|
||||||
|
console.error('[proxy] initial refresh failed:', error);
|
||||||
|
});
|
||||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<title>marathon.todo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/public/fonts/Ki-Bold.otf
Normal file
BIN
frontend/public/fonts/Ki-Regular.otf
Normal file
814
frontend/src/App.tsx
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { buildImageSearchCandidates, getAllItems, getPopularResults, searchResultsByName, trackResultAdded } from './marathonApi';
|
||||||
|
import type { SearchItem, SearchResult, TodoItem, UpgradeLevel, UpgradeSearchResult } from './types';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'marathon.todo.items';
|
||||||
|
const SHARE_PARAM = 'l';
|
||||||
|
const SHARE_VERSION = '1';
|
||||||
|
const RARITY_GLOW_COLORS: Record<string, string> = {
|
||||||
|
standard: '#CACAD6',
|
||||||
|
enhanced: '#00FF7D',
|
||||||
|
deluxe: '#21B4FA',
|
||||||
|
superior: '#E912F6',
|
||||||
|
prestige: '#FFF40C',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRarityGlowColor(rarity?: string): string | null {
|
||||||
|
if (!rarity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RARITY_GLOW_COLORS[rarity.trim().toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpgradeResult(result: SearchResult): result is UpgradeSearchResult {
|
||||||
|
return 'isUpgrade' in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemIcon({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
size = 32,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const [candidateIndex, setCandidateIndex] = useState(0);
|
||||||
|
const candidates = useMemo(() => buildImageSearchCandidates(src), [src]);
|
||||||
|
const currentCandidate = candidates[candidateIndex] ?? src;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCandidateIndex(0);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={currentCandidate}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => {
|
||||||
|
if (candidates.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCandidateIndex((current) => Math.min(current + 1, candidates.length - 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTodoItems(): TodoItem[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.filter((row): row is TodoItem => {
|
||||||
|
return (
|
||||||
|
typeof row === 'object' &&
|
||||||
|
row !== null &&
|
||||||
|
typeof row.id === 'string' &&
|
||||||
|
typeof row.slug === 'string' &&
|
||||||
|
typeof row.name === 'string' &&
|
||||||
|
typeof row.iconUrl === 'string' &&
|
||||||
|
(row.rarity === undefined || typeof row.rarity === 'string') &&
|
||||||
|
typeof row.quantity === 'number' &&
|
||||||
|
typeof row.completed === 'boolean'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
rarity: typeof item.rarity === 'string' ? item.rarity : undefined,
|
||||||
|
quantity: Math.max(1, Math.floor(item.quantity)),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(value: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeListForUrl(items: TodoItem[]): string {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = items
|
||||||
|
.map((item) => {
|
||||||
|
const quantity = Math.max(1, Math.floor(item.quantity));
|
||||||
|
return `${encodeURIComponent(item.slug)},${quantity},${item.completed ? 1 : 0}`;
|
||||||
|
})
|
||||||
|
.join(';');
|
||||||
|
|
||||||
|
return toBase64Url(`${SHARE_VERSION}|${payload}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeListFromUrl(encoded: string, allItems: SearchItem[]): TodoItem[] | null {
|
||||||
|
const decoded = fromBase64Url(encoded);
|
||||||
|
if (!decoded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [version, rawItems = ''] = decoded.split('|', 2);
|
||||||
|
if (version !== SHARE_VERSION) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawItems) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySlug = new Map(allItems.map((item) => [item.slug, item]));
|
||||||
|
|
||||||
|
const mapped = rawItems
|
||||||
|
.split(';')
|
||||||
|
.map((entry, index): TodoItem | null => {
|
||||||
|
const [rawSlug, rawQuantity, rawCompleted] = entry.split(',', 3);
|
||||||
|
if (!rawSlug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = decodeURIComponent(rawSlug);
|
||||||
|
const match = bySlug.get(slug);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Math.max(1, Number(rawQuantity) || 1);
|
||||||
|
const completed = rawCompleted === '1';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${slug}-${index}`,
|
||||||
|
slug: match.slug,
|
||||||
|
name: match.name,
|
||||||
|
iconUrl: match.iconUrl,
|
||||||
|
rarity: match.rarity,
|
||||||
|
quantity,
|
||||||
|
completed,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is TodoItem => item !== null);
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [minimalMode, setMinimalMode] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [todoItems, setTodoItems] = useState<TodoItem[]>(() => loadTodoItems());
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
const [popularResults, setPopularResults] = useState<SearchResult[]>([]);
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
const [popularLoading, setPopularLoading] = useState(false);
|
||||||
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||||
|
const [highlightColorsBySlug, setHighlightColorsBySlug] = useState<Record<string, string>>({});
|
||||||
|
const highlightTimeoutsRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const initializeCatalogAndSharedList = async () => {
|
||||||
|
const sharedState = new URL(window.location.href).searchParams.get(SHARE_PARAM);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allItems = await getAllItems();
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rarityBySlug = new Map(allItems.map((item) => [item.slug, item.rarity]));
|
||||||
|
setTodoItems((prevItems) =>
|
||||||
|
prevItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
rarity: item.rarity ?? rarityBySlug.get(item.slug),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sharedState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedItems = decodeListFromUrl(sharedState, allItems);
|
||||||
|
if (decodedItems) {
|
||||||
|
setTodoItems(decodedItems);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled && sharedState) {
|
||||||
|
setSearchError('Failed to load shared list.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void initializeCatalogAndSharedList();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(todoItems));
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const encoded = encodeListForUrl(todoItems);
|
||||||
|
if (encoded) {
|
||||||
|
url.searchParams.set(SHARE_PARAM, encoded);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(SHARE_PARAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
|
if (nextUrl !== currentUrl) {
|
||||||
|
window.history.replaceState(null, '', nextUrl);
|
||||||
|
}
|
||||||
|
}, [todoItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actionMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => setActionMessage(null), 2200);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [actionMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const timeoutId of Object.values(highlightTimeoutsRef.current)) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
setResults([]);
|
||||||
|
setSearchError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
setSearchLoading(true);
|
||||||
|
setSearchError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matchedResults = await searchResultsByName(trimmedQuery, 5);
|
||||||
|
setResults(matchedResults);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setSearchError(message);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const completedCount = useMemo(() => todoItems.filter((item) => item.completed).length, [todoItems]);
|
||||||
|
|
||||||
|
function triggerTodoHighlights(slugs: string[], color: string): void {
|
||||||
|
const uniqueSlugs = Array.from(new Set(slugs));
|
||||||
|
if (uniqueSlugs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightColorsBySlug((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const slug of uniqueSlugs) {
|
||||||
|
delete next[slug];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setHighlightColorsBySlug((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const slug of uniqueSlugs) {
|
||||||
|
next[slug] = color;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const slug of uniqueSlugs) {
|
||||||
|
const existingTimeoutId = highlightTimeoutsRef.current[slug];
|
||||||
|
if (existingTimeoutId) {
|
||||||
|
window.clearTimeout(existingTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightTimeoutsRef.current[slug] = window.setTimeout(() => {
|
||||||
|
setHighlightColorsBySlug((prev) => {
|
||||||
|
if (!prev[slug]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slug];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
delete highlightTimeoutsRef.current[slug];
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTodoItem(item: SearchItem): void {
|
||||||
|
setTodoItems((prevItems) => {
|
||||||
|
const existing = prevItems.find((entry) => entry.slug === item.slug);
|
||||||
|
if (existing) {
|
||||||
|
return prevItems.map((entry) =>
|
||||||
|
entry.slug === item.slug ? { ...entry, quantity: entry.quantity + 1 } : entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${item.slug}-${Date.now()}`,
|
||||||
|
slug: item.slug,
|
||||||
|
name: item.name,
|
||||||
|
iconUrl: item.iconUrl,
|
||||||
|
rarity: item.rarity,
|
||||||
|
quantity: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
...prevItems,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUpgradeLevelSalvage(upgrade: UpgradeSearchResult, level: UpgradeLevel): void {
|
||||||
|
if (level.salvage.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTodoItems((prevItems) => {
|
||||||
|
const nextItems = [...prevItems];
|
||||||
|
|
||||||
|
for (const salvageEntry of level.salvage) {
|
||||||
|
const { slug } = salvageEntry;
|
||||||
|
const amount = Math.max(1, Math.floor(salvageEntry.amount));
|
||||||
|
const existingIndex = nextItems.findIndex((entry) => entry.slug === slug);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
nextItems[existingIndex] = {
|
||||||
|
...nextItems[existingIndex],
|
||||||
|
quantity: nextItems[existingIndex].quantity + amount,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextItems.unshift({
|
||||||
|
id: `${slug}-${Date.now()}-${amount}`,
|
||||||
|
slug,
|
||||||
|
name: salvageEntry.name,
|
||||||
|
iconUrl: salvageEntry.iconUrl,
|
||||||
|
rarity: salvageEntry.rarity,
|
||||||
|
quantity: amount,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextItems;
|
||||||
|
});
|
||||||
|
|
||||||
|
triggerTodoHighlights(
|
||||||
|
level.salvage.map((entry) => entry.slug),
|
||||||
|
upgrade.factionColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSearchResult(result: SearchResult): void {
|
||||||
|
if (isUpgradeResult(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void trackResultAdded(result);
|
||||||
|
addTodoItem(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearchFocus(): Promise<void> {
|
||||||
|
setSearchFocused(true);
|
||||||
|
|
||||||
|
if (query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopularLoading(true);
|
||||||
|
try {
|
||||||
|
const popular = await getPopularResults(5);
|
||||||
|
setPopularResults(popular);
|
||||||
|
} catch {
|
||||||
|
setPopularResults([]);
|
||||||
|
} finally {
|
||||||
|
setPopularLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuantity(id: string, quantity: number): void {
|
||||||
|
setTodoItems((prevItems) =>
|
||||||
|
prevItems.map((item) => (item.id === id ? { ...item, quantity: Math.max(1, quantity) } : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementQuantity(id: string): void {
|
||||||
|
setTodoItems((prevItems) =>
|
||||||
|
prevItems.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementQuantity(id: string): void {
|
||||||
|
setTodoItems((prevItems) =>
|
||||||
|
prevItems.map((item) =>
|
||||||
|
item.id === id ? { ...item, quantity: Math.max(1, item.quantity - 1) } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCompleted(id: string): void {
|
||||||
|
setTodoItems((prevItems) =>
|
||||||
|
prevItems.map((item) => (item.id === id ? { ...item, completed: !item.completed } : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItem(id: string): void {
|
||||||
|
setTodoItems((prevItems) => prevItems.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareList(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
setActionMessage('Share link copied.');
|
||||||
|
} catch {
|
||||||
|
setActionMessage('Failed to copy share link.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetList(): void {
|
||||||
|
setTodoItems([]);
|
||||||
|
setActionMessage('List reset.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={minimalMode ? 'app minimal-mode' : 'app'}>
|
||||||
|
<header>
|
||||||
|
<div className="header-top">
|
||||||
|
<div>
|
||||||
|
<h1>marathon.todo</h1>
|
||||||
|
{!minimalMode ? <p>Plan what to loot (or do) in your next Marathon raid.</p> : null}
|
||||||
|
</div>
|
||||||
|
{!minimalMode ? (
|
||||||
|
<button type="button" className="header-btn" onClick={() => setMinimalMode(true)}>
|
||||||
|
Minimal mode
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="header-btn" onClick={() => setMinimalMode(false)}>
|
||||||
|
Exit minimal mode
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!minimalMode ? (
|
||||||
|
<section className="card">
|
||||||
|
<label htmlFor="item-search">Search items</label>
|
||||||
|
<div className="search-box">
|
||||||
|
<input
|
||||||
|
id="item-search"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onFocus={() => void handleSearchFocus()}
|
||||||
|
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
|
||||||
|
placeholder="Type an item name..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{searchLoading ? <p className="status search-status">Searching...</p> : null}
|
||||||
|
{searchError ? <p className="status error search-status">Search failed: {searchError}</p> : null}
|
||||||
|
|
||||||
|
{query.trim() && !searchLoading && !searchError ? (
|
||||||
|
<ul className="results" aria-label="Search results">
|
||||||
|
{results.length === 0 ? <li className="status">No matching items.</li> : null}
|
||||||
|
{results.map((item) => (
|
||||||
|
<li key={item.slug}>
|
||||||
|
{isUpgradeResult(item) ? (
|
||||||
|
<div className="result-row upgrade-row">
|
||||||
|
<div className="result-main">
|
||||||
|
<span
|
||||||
|
className="result-icon-frame"
|
||||||
|
style={
|
||||||
|
({
|
||||||
|
'--rarity-glow-color': item.factionColor || '#3a4f77',
|
||||||
|
} as React.CSSProperties)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon src={item.iconUrl} alt="" />
|
||||||
|
</span>
|
||||||
|
<span className="result-name">{item.name}</span>
|
||||||
|
<span
|
||||||
|
className="upgrade-tag"
|
||||||
|
style={{
|
||||||
|
color: item.factionColor || '#a9b8d6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.factionName} upgrade
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="upgrade-levels" aria-label={`Levels for ${item.name}`}>
|
||||||
|
<span className="level-label">Level:</span>
|
||||||
|
{item.levels.map((level) => (
|
||||||
|
<button
|
||||||
|
key={`${item.slug}-level-${level.level}`}
|
||||||
|
type="button"
|
||||||
|
className="level-button"
|
||||||
|
disabled={level.salvage.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
void trackResultAdded(item);
|
||||||
|
addUpgradeLevelSalvage(item, level);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{level.level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => addSearchResult(item)}>
|
||||||
|
<span
|
||||||
|
className="result-icon-frame"
|
||||||
|
style={
|
||||||
|
getRarityGlowColor(item.rarity)
|
||||||
|
? ({ '--rarity-glow-color': getRarityGlowColor(item.rarity) } as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon src={item.iconUrl} alt="" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="result-name"
|
||||||
|
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!query.trim() && searchFocused ? (
|
||||||
|
<>
|
||||||
|
<ul className="results" aria-label="Popular picks">
|
||||||
|
<li className="status">Popular picks</li>
|
||||||
|
{popularLoading ? <li className="status">Loading popular picks...</li> : null}
|
||||||
|
{!popularLoading && popularResults.length === 0 ? <li className="status">No popular picks yet.</li> : null}
|
||||||
|
{!popularLoading
|
||||||
|
? popularResults.map((item) => (
|
||||||
|
<li key={`popular-${item.slug}`}>
|
||||||
|
{isUpgradeResult(item) ? (
|
||||||
|
<div className="result-row upgrade-row">
|
||||||
|
<div className="result-main">
|
||||||
|
<span
|
||||||
|
className="result-icon-frame"
|
||||||
|
style={
|
||||||
|
({
|
||||||
|
'--rarity-glow-color': item.factionColor || '#3a4f77',
|
||||||
|
} as React.CSSProperties)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon src={item.iconUrl} alt="" />
|
||||||
|
</span>
|
||||||
|
<span className="result-name">{item.name}</span>
|
||||||
|
<span
|
||||||
|
className="upgrade-tag"
|
||||||
|
style={{
|
||||||
|
color: item.factionColor || '#a9b8d6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.factionName} upgrade
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="upgrade-levels" aria-label={`Levels for ${item.name}`}>
|
||||||
|
<span className="level-label">Level:</span>
|
||||||
|
{item.levels.map((level) => (
|
||||||
|
<button
|
||||||
|
key={`popular-${item.slug}-level-${level.level}`}
|
||||||
|
type="button"
|
||||||
|
className="level-button"
|
||||||
|
disabled={level.salvage.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
void trackResultAdded(item);
|
||||||
|
addUpgradeLevelSalvage(item, level);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{level.level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => addSearchResult(item)}>
|
||||||
|
<span
|
||||||
|
className="result-icon-frame"
|
||||||
|
style={
|
||||||
|
getRarityGlowColor(item.rarity)
|
||||||
|
? ({ '--rarity-glow-color': getRarityGlowColor(item.rarity) } as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon src={item.iconUrl} alt="" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="result-name"
|
||||||
|
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="list-header">
|
||||||
|
<h2>To-do list</h2>
|
||||||
|
{!minimalMode && todoItems.length > 0 ? (
|
||||||
|
<div className="header-actions">
|
||||||
|
<button type="button" className="header-btn" onClick={() => void shareList()}>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
<button type="button" className="header-btn danger" onClick={resetList}>
|
||||||
|
Reset list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!minimalMode ? (
|
||||||
|
<p className="status">
|
||||||
|
Completed {completedCount} / {todoItems.length}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!minimalMode && actionMessage ? <p className="status">{actionMessage}</p> : null}
|
||||||
|
|
||||||
|
{todoItems.length === 0 ? (
|
||||||
|
<p className="status">{minimalMode ? 'No items yet.' : 'No items yet. Add one from search.'}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className={minimalMode ? 'todo-list minimal-list' : 'todo-list'} aria-label="To-do items">
|
||||||
|
{todoItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={
|
||||||
|
minimalMode
|
||||||
|
? `todo-item minimal-item${item.completed ? ' done' : ''}`
|
||||||
|
: item.completed
|
||||||
|
? `todo-item done${highlightColorsBySlug[item.slug] ? ' highlighted' : ''}`
|
||||||
|
: `todo-item${highlightColorsBySlug[item.slug] ? ' highlighted' : ''}`
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
!minimalMode && highlightColorsBySlug[item.slug]
|
||||||
|
? ({ '--highlight-color': highlightColorsBySlug[item.slug] } as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => toggleCompleted(item.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="todo-icon-frame"
|
||||||
|
style={
|
||||||
|
getRarityGlowColor(item.rarity)
|
||||||
|
? ({ '--rarity-glow-color': getRarityGlowColor(item.rarity) } as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon src={item.iconUrl} alt="" size={37} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="item-name"
|
||||||
|
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{minimalMode ? (
|
||||||
|
<span className="minimal-qty">x{item.quantity}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="quantity-field"
|
||||||
|
aria-label={`Quantity controls for ${item.name}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button type="button" className="qty-arrow" onClick={() => decrementQuantity(item.id)}>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(event) => updateQuantity(item.id, Number(event.target.value) || 1)}
|
||||||
|
aria-label={`Quantity for ${item.name}`}
|
||||||
|
/>
|
||||||
|
<button type="button" className="qty-arrow" onClick={() => incrementQuantity(item.id)}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="delete"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteItem(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!minimalMode ? (
|
||||||
|
<footer className="legal-footer" aria-label="Legal information">
|
||||||
|
<section className="legal-section" aria-labelledby="privacy-heading">
|
||||||
|
<h2 id="privacy-heading">Privacy Policy</h2>
|
||||||
|
<p>
|
||||||
|
This site stores your to-do list locally in your browser so your list can persist between visits and be
|
||||||
|
shared by link. No accounts are required and no personal data is sold.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section" aria-labelledby="contact-heading">
|
||||||
|
<h2 id="contact-heading">Contact</h2>
|
||||||
|
<p>
|
||||||
|
E-mail: <a href="mailto:alshuriga@gmail.com">alshuriga@gmail.com</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section" aria-labelledby="legal-heading">
|
||||||
|
<h2 id="legal-heading">Info</h2>
|
||||||
|
<p>Marathon™ is owned by Bungie, Inc. This website is unofficial and has no affiliation with or endorsement from Bungie.</p>
|
||||||
|
</section>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
157
frontend/src/marathonApi.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { SearchItem, SearchResult, UpgradeSearchResult } from './types';
|
||||||
|
|
||||||
|
const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|webp)(?:[?#].*)?$/i;
|
||||||
|
const IMAGE_FALLBACK_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp'] as const;
|
||||||
|
|
||||||
|
interface CatalogResponse {
|
||||||
|
updatedAt: string;
|
||||||
|
items: SearchItem[];
|
||||||
|
upgrades: UpgradeSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopularResponse {
|
||||||
|
picks: SearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalogPromise: Promise<CatalogResponse> | null = null;
|
||||||
|
|
||||||
|
function normalizeCatalog(payload: CatalogResponse): CatalogResponse {
|
||||||
|
const normalizedUpgrades = payload.upgrades.map((upgrade) => {
|
||||||
|
if (Array.isArray(upgrade.levels)) {
|
||||||
|
return upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyUpgrade = upgrade as UpgradeSearchResult & { salvage?: UpgradeSearchResult['levels'][number]['salvage'] };
|
||||||
|
const salvage = Array.isArray(legacyUpgrade.salvage) ? legacyUpgrade.salvage : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...upgrade,
|
||||||
|
levels: [{ level: 1, salvage }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
upgrades: normalizedUpgrades,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreResult(name: string, query: string): number {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerName === query) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerName.startsWith(query)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = lowerName.indexOf(query);
|
||||||
|
if (index >= 0) {
|
||||||
|
return 2 + index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCatalog(): Promise<CatalogResponse> {
|
||||||
|
const response = await fetch('/api/catalog');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch catalog: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as CatalogResponse;
|
||||||
|
if (!Array.isArray(payload.items) || !Array.isArray(payload.upgrades)) {
|
||||||
|
throw new Error('Invalid catalog payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCatalog(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCatalog(): Promise<CatalogResponse> {
|
||||||
|
if (!catalogPromise) {
|
||||||
|
catalogPromise = fetchCatalog().catch((error: unknown) => {
|
||||||
|
catalogPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalogPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImageSearchCandidates(iconUrl: string): string[] {
|
||||||
|
if (!iconUrl) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IMAGE_EXTENSION_RE.test(iconUrl)) {
|
||||||
|
return [iconUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [iconUrl, ...IMAGE_FALLBACK_EXTENSIONS.map((ext) => `${iconUrl}.${ext}`)];
|
||||||
|
return Array.from(new Set(candidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllItems(): Promise<SearchItem[]> {
|
||||||
|
const catalog = await getCatalog();
|
||||||
|
return catalog.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchResultsByName(query: string, limit = 5): Promise<SearchResult[]> {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await getCatalog();
|
||||||
|
|
||||||
|
const candidates: SearchResult[] = [
|
||||||
|
...catalog.items.filter((item) => item.name.toLowerCase().includes(normalizedQuery)),
|
||||||
|
...catalog.upgrades.filter((upgrade) => upgrade.name.toLowerCase().includes(normalizedQuery)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.sort((a, b) => {
|
||||||
|
const scoreDiff = scoreResult(a.name, normalizedQuery) - scoreResult(b.name, normalizedQuery);
|
||||||
|
if (scoreDiff !== 0) {
|
||||||
|
return scoreDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPopularResults(limit = 5): Promise<SearchResult[]> {
|
||||||
|
const response = await fetch(`/api/popularity?limit=${Math.max(1, Math.floor(limit))}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch popular picks: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as PopularResponse;
|
||||||
|
if (!Array.isArray(payload.picks)) {
|
||||||
|
throw new Error('Invalid popular picks payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.picks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trackResultAdded(result: SearchResult): Promise<void> {
|
||||||
|
const payload = {
|
||||||
|
type: 'isUpgrade' in result ? 'upgrade' : 'item',
|
||||||
|
slug: result.slug,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/popularity/track', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore analytics failures so add-to-list UX is never blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
509
frontend/src/styles.css
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Ki-Regular';
|
||||||
|
src: url('/fonts/Ki-Regular.otf') format('opentype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ki-Bold';
|
||||||
|
src: url('/fonts/Ki-Bold.otf') format('opentype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ui-dark: #1e2125;
|
||||||
|
--ui-dark-elevated: #212529;
|
||||||
|
--ui-accent: #caf61d;
|
||||||
|
--color-text-main: #e8edf7;
|
||||||
|
--color-text-input: #f2f6ff;
|
||||||
|
--color-text-muted: #d6dbe8;
|
||||||
|
--color-text-done: #8d9bbb;
|
||||||
|
--color-border: #444444;
|
||||||
|
--color-hover: #31373d;
|
||||||
|
--color-error: #ff9aa2;
|
||||||
|
--color-rarity-fallback: #6f6f6f;
|
||||||
|
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||||
|
--shadow-dropdown: 0 14px 34px rgba(2, 6, 12, 0.65);
|
||||||
|
--control-height: 34px;
|
||||||
|
font-family: 'Ki-Regular', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
background: var(--ui-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
background: var(--ui-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: 'Ki-Regular', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: 'Ki-Bold', 'Ki-Regular', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: var(--ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
border-radius: 0;
|
||||||
|
height: var(--control-height);
|
||||||
|
min-height: var(--control-height);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn.danger {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn.danger:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='search'],
|
||||||
|
input[type='number'] {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--color-text-input);
|
||||||
|
background: var(--ui-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results,
|
||||||
|
.todo-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--ui-dark);
|
||||||
|
box-shadow: var(--shadow-dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results button {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 30%, transparent),
|
||||||
|
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 10%, transparent)
|
||||||
|
);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon-frame::before,
|
||||||
|
.todo-icon-frame::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon-frame > img,
|
||||||
|
.todo-icon-frame > img {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-icon-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 25%, transparent),
|
||||||
|
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 10%, transparent)
|
||||||
|
);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
margin-right: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.8rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results button:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-levels {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ui-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-button {
|
||||||
|
width: 28px !important;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
max-width: 28px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-button:hover:not(:disabled) {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimal-item {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimal-item:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimal-qty {
|
||||||
|
color: var(--ui-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.highlighted {
|
||||||
|
animation: todo-highlight 1.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-field input {
|
||||||
|
width: 72px;
|
||||||
|
text-align: center;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-field input::-webkit-outer-spin-button,
|
||||||
|
.quantity-field input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-arrow {
|
||||||
|
width: var(--control-height);
|
||||||
|
height: var(--control-height);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-arrow:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.done .item-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.done .minimal-qty,
|
||||||
|
.todo-item.done .quantity-field input {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.done {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes todo-highlight {
|
||||||
|
0% {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color, var(--color-rarity-fallback)) 24%, var(--ui-dark-elevated));
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color, var(--color-rarity-fallback)) 16%, var(--ui-dark-elevated));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
border-radius: 0;
|
||||||
|
height: var(--control-height);
|
||||||
|
min-height: var(--control-height);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: var(--ui-accent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--ui-dark-elevated);
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section h2 {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section a {
|
||||||
|
color: var(--ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.header-top {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimal-item {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-field,
|
||||||
|
.delete {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface SearchItem {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
rarity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeSalvageItem {
|
||||||
|
slug: string;
|
||||||
|
amount: number;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
rarity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeLevel {
|
||||||
|
level: number;
|
||||||
|
salvage: UpgradeSalvageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeSearchResult {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
factionName: string;
|
||||||
|
factionColor: string;
|
||||||
|
levels: UpgradeLevel[];
|
||||||
|
isUpgrade: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchResult = SearchItem | UpgradeSearchResult;
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
rarity?: string;
|
||||||
|
quantity: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
1729
package-lock.json
generated
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "marathon-todo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"proxy": "node backend/server.js",
|
||||||
|
"dev:frontend": "vite",
|
||||||
|
"dev:backend": "node backend/server.js",
|
||||||
|
"dev:all": "node scripts/dev-all.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
project.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# marathon.todo
|
||||||
|
|
||||||
|
A web application that allows users to create a to-do list of what to loot (or do) in raid in Bungie's Marathon video game.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Frontend-only
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
https://marathondb.gg/ provides a public API with all the necessary game data.
|
||||||
|
|
||||||
|
The `app.js` file downloaded from this site can be used to generate a TypeScript client for the API.
|
||||||
|
|
||||||
|
## Project Rules
|
||||||
|
- Save all added features and changes (except very minor ones) to the `CHANGELOG.md` file.
|
||||||
|
- Always update the `README.md` file accordingly.
|
||||||
|
|
||||||
|
## Initial Features
|
||||||
|
- User can search for an item by name.
|
||||||
|
- Search results display up to 5 relevant items with their icons.
|
||||||
|
- User can add an item to the to-do list by clicking on it.
|
||||||
|
- User can specify the quantity for each added item.
|
||||||
|
- User can mark items as completed.
|
||||||
|
- User can delete items from the to-do list.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
- Keep the UI very simple for now.
|
||||||
|
- Minimal styling.
|
||||||
62
scripts/dev-all.mjs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
function spawnNpmScript(scriptName) {
|
||||||
|
const npmExecPath = process.env.npm_execpath;
|
||||||
|
if (npmExecPath) {
|
||||||
|
return spawn(process.execPath, [npmExecPath, 'run', scriptName], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
return spawn(npmCommand, ['run', scriptName], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = spawnNpmScript('dev:backend');
|
||||||
|
const frontend = spawnNpmScript('dev:frontend');
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
function shutdown(code = 0) {
|
||||||
|
if (shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shuttingDown = true;
|
||||||
|
|
||||||
|
if (backend && !backend.killed) {
|
||||||
|
backend.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frontend && !frontend.killed) {
|
||||||
|
frontend.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.on('exit', (code) => {
|
||||||
|
shutdown(code ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
frontend.on('exit', (code) => {
|
||||||
|
shutdown(code ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
backend.on('error', (error) => {
|
||||||
|
console.error('[dev:all] backend failed:', error);
|
||||||
|
shutdown(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
frontend.on('error', (error) => {
|
||||||
|
console.error('[dev:all] frontend failed:', error);
|
||||||
|
shutdown(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown(0));
|
||||||
|
process.on('SIGTERM', () => shutdown(0));
|
||||||
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["frontend/src"]
|
||||||
|
}
|
||||||
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'frontend',
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8787',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://localhost:8787',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||