Phase 2: market data layer

AODP price client (chunked, throttled, proxy-aware), snapshot storage
with freshness ages, manual price overrides that outrank API data,
async refresh job with progress, Market Data + Settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 02:59:06 +05:00
parent 7ba275dd77
commit 7ff3e6ec95
11 changed files with 832 additions and 2 deletions

12
package-lock.json generated
View file

@ -3422,6 +3422,15 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@ -3729,7 +3738,8 @@
"version": "0.1.0",
"dependencies": {
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0"
"fastify": "^5.4.0",
"undici": "^8.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",

View file

@ -12,7 +12,8 @@
},
"dependencies": {
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0"
"fastify": "^5.4.0",
"undici": "^8.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",

View file

@ -43,6 +43,30 @@ CREATE TABLE IF NOT EXISTS recipe_inputs (
PRIMARY KEY (recipe_id, input_item)
);
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item);
CREATE TABLE IF NOT EXISTS market_prices (
item TEXT NOT NULL,
city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1,
sell_price_min REAL,
sell_min_date TEXT, -- when AODP last observed this sell order (UTC ISO)
buy_price_max REAL,
buy_max_date TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (item, city, quality)
);
CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city);
CREATE TABLE IF NOT EXISTS price_overrides (
item TEXT NOT NULL,
city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1,
sell_price REAL,
buy_price REAL,
note TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (item, city, quality)
);
`;
let db: Database.Database | null = null;

View file

@ -3,6 +3,7 @@ import { API_PORT } from './config.js';
import { getDb } from './db/index.js';
import { gamedataRoutes } from './routes/gamedata.js';
import { itemRoutes } from './routes/items.js';
import { marketRoutes } from './routes/market.js';
const app = Fastify({ logger: { level: 'info' } });
@ -11,6 +12,7 @@ getDb(); // initialize schema on startup
app.get('/api/health', async () => ({ ok: true }));
await app.register(gamedataRoutes);
await app.register(itemRoutes);
await app.register(marketRoutes);
await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`);

116
server/src/market/client.ts Normal file
View file

@ -0,0 +1,116 @@
/** Client for the Albion Online Data Project (AODP) REST API. */
import { fetch as undiciFetch, ProxyAgent } from 'undici';
import { getMeta } from '../db/index.js';
/**
* AODP is blocked in some regions. If the user's VPN is browser-only, they can
* instead set a proxy (Settings page, or the HTTPS_PROXY env var) and the app
* routes its market requests through it.
*/
function getProxyUrl(): string | null {
return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null;
}
async function proxyAwareFetch(url: string, timeoutMs: number): Promise<{ ok: boolean; status: number; json(): Promise<unknown> }> {
const proxyUrl = getProxyUrl();
const signal = AbortSignal.timeout(timeoutMs);
if (proxyUrl) {
return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) });
}
return fetch(url, { signal });
}
export const SERVER_HOSTS: Record<string, string> = {
europe: 'europe.albion-online-data.com',
americas: 'west.albion-online-data.com',
asia: 'east.albion-online-data.com',
};
/** Locations with player markets we track by default. */
export const CITIES = [
'Bridgewatch',
'Caerleon',
'Fort Sterling',
'Lymhurst',
'Martlock',
'Thetford',
'Brecilien',
'Black Market',
] as const;
export interface AodpPriceRow {
item_id: string;
city: string;
quality: number;
sell_price_min: number;
sell_price_min_date: string;
sell_price_max: number;
sell_price_max_date: string;
buy_price_max: number;
buy_price_max_date: string;
buy_price_min: number;
buy_price_min_date: string;
}
const CHUNK_SIZE = 100; // item ids per request (keeps URLs short)
const REQUEST_DELAY_MS = 500; // stay well under AODP's ~180 req/min limit
const REQUEST_TIMEOUT_MS = 20000;
/** AODP uses 0001-01-01T00:00:00 as "never observed". */
export function normalizeAodpDate(d: string | null | undefined): string | null {
if (!d || d.startsWith('0001-')) return null;
return d.endsWith('Z') ? d : `${d}Z`; // AODP timestamps are UTC without the Z
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export class AodpUnreachableError extends Error {
constructor(cause: unknown) {
super(
'Market data API (albion-online-data.com) is unreachable. ' +
'If you are in a region where it is blocked, check that your VPN is on. ' +
`(${cause instanceof Error ? cause.message : String(cause)})`,
);
this.name = 'AodpUnreachableError';
}
}
export interface FetchPricesOptions {
server: string; // key of SERVER_HOSTS
items: string[];
cities?: string[];
qualities?: number[];
onProgress?: (done: number, total: number) => void;
}
export async function fetchPrices(opts: FetchPricesOptions): Promise<AodpPriceRow[]> {
const host = SERVER_HOSTS[opts.server] ?? SERVER_HOSTS.europe;
const cities = opts.cities ?? [...CITIES];
const qualities = opts.qualities ?? [1, 2, 3, 4, 5];
const chunks: string[][] = [];
for (let i = 0; i < opts.items.length; i += CHUNK_SIZE) {
chunks.push(opts.items.slice(i, i + CHUNK_SIZE));
}
const all: AodpPriceRow[] = [];
for (let i = 0; i < chunks.length; i++) {
const url =
`https://${host}/api/v2/stats/prices/${encodeURIComponent(chunks[i].join(','))}` +
`?locations=${encodeURIComponent(cities.join(','))}` +
`&qualities=${qualities.join(',')}`;
try {
const res = await proxyAwareFetch(url, REQUEST_TIMEOUT_MS);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rows = (await res.json()) as AodpPriceRow[];
all.push(...rows);
} catch (e) {
throw new AodpUnreachableError(e);
}
opts.onProgress?.(i + 1, chunks.length);
if (i < chunks.length - 1) await sleep(REQUEST_DELAY_MS);
}
return all;
}

View file

@ -0,0 +1,249 @@
import { getDb, getMeta, setMeta } from '../db/index.js';
import {
AodpUnreachableError,
CITIES,
fetchPrices,
normalizeAodpDate,
SERVER_HOSTS,
} from './client.js';
export function getAlbionServer(): string {
const s = getMeta('albion_server');
return s && s in SERVER_HOSTS ? s : 'europe';
}
export function setAlbionServer(server: string): void {
if (!(server in SERVER_HOSTS)) throw new Error(`unknown server: ${server}`);
setMeta('albion_server', server);
}
// ---------------------------------------------------------------------------
// Refresh job (long-running; started async, polled by the UI)
export interface RefreshJob {
status: 'idle' | 'running' | 'done' | 'error';
totalChunks: number;
doneChunks: number;
itemCount: number;
rowsUpserted: number;
error?: string;
startedAt?: string;
finishedAt?: string;
}
let job: RefreshJob = { status: 'idle', totalChunks: 0, doneChunks: 0, itemCount: 0, rowsUpserted: 0 };
export function getRefreshJob(): RefreshJob {
return job;
}
export interface RefreshOptions {
minTier?: number;
maxTier?: number;
cities?: string[];
items?: string[]; // explicit list overrides tier-based selection
}
/** Items worth pricing: everything that appears in a recipe (as output or input). */
function selectRecipeRelevantItems(minTier: number, maxTier: number): string[] {
const rows = getDb()
.prepare(
`SELECT DISTINCT i.unique_name AS name
FROM items i
WHERE i.tier BETWEEN ? AND ?
AND (
EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name)
OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name)
)`,
)
.all(minTier, maxTier) as { name: string }[];
return rows.map((r) => r.name);
}
export function startRefresh(opts: RefreshOptions): { started: boolean; reason?: string } {
if (job.status === 'running') return { started: false, reason: 'a refresh is already running' };
const items =
opts.items && opts.items.length > 0
? opts.items
: selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
if (items.length === 0) return { started: false, reason: 'no items match the selection' };
const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES];
job = {
status: 'running',
totalChunks: Math.ceil(items.length / 100),
doneChunks: 0,
itemCount: items.length,
rowsUpserted: 0,
startedAt: new Date().toISOString(),
};
void (async () => {
try {
const rows = await fetchPrices({
server: getAlbionServer(),
items,
cities,
onProgress: (done, total) => {
job.doneChunks = done;
job.totalChunks = total;
},
});
const db = getDb();
const upsert = db.prepare(`
INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(item, city, quality) DO UPDATE SET
sell_price_min = excluded.sell_price_min,
sell_min_date = excluded.sell_min_date,
buy_price_max = excluded.buy_price_max,
buy_max_date = excluded.buy_max_date,
fetched_at = excluded.fetched_at
`);
const now = new Date().toISOString();
let upserted = 0;
db.transaction(() => {
for (const r of rows) {
const sellDate = normalizeAodpDate(r.sell_price_min_date);
const buyDate = normalizeAodpDate(r.buy_price_max_date);
if (!sellDate && !buyDate) continue; // never observed anywhere
upsert.run(
r.item_id,
r.city,
r.quality,
r.sell_price_min > 0 ? r.sell_price_min : null,
sellDate,
r.buy_price_max > 0 ? r.buy_price_max : null,
buyDate,
now,
);
upserted++;
}
})();
job.rowsUpserted = upserted;
job.status = 'done';
job.finishedAt = new Date().toISOString();
setMeta('market_last_refresh', job.finishedAt);
} catch (e) {
job.status = 'error';
job.finishedAt = new Date().toISOString();
job.error = e instanceof AodpUnreachableError ? e.message : String(e);
}
})();
return { started: true };
}
// ---------------------------------------------------------------------------
// Reads (always merge manual overrides over API data)
export interface MergedPrice {
item: string;
city: string;
quality: number;
sellPrice: number | null;
buyPrice: number | null;
sellSource: 'api' | 'manual' | null;
buySource: 'api' | 'manual' | null;
sellAgeHours: number | null; // null for manual overrides / missing
buyAgeHours: number | null;
note: string | null;
}
function ageHours(iso: string | null): number | null {
if (!iso) return null;
const t = Date.parse(iso);
if (!Number.isFinite(t)) return null;
return Math.max(0, (Date.now() - t) / 3600000);
}
export function getPricesForItem(item: string): MergedPrice[] {
const db = getDb();
const api = db.prepare('SELECT * FROM market_prices WHERE item = ?').all(item) as any[];
const overrides = db.prepare('SELECT * FROM price_overrides WHERE item = ?').all(item) as any[];
const byKey = new Map<string, MergedPrice>();
for (const r of api) {
byKey.set(`${r.city}|${r.quality}`, {
item,
city: r.city,
quality: r.quality,
sellPrice: r.sell_price_min,
buyPrice: r.buy_price_max,
sellSource: r.sell_price_min != null ? 'api' : null,
buySource: r.buy_price_max != null ? 'api' : null,
sellAgeHours: ageHours(r.sell_min_date),
buyAgeHours: ageHours(r.buy_max_date),
note: null,
});
}
for (const o of overrides) {
const key = `${o.city}|${o.quality}`;
const existing = byKey.get(key) ?? {
item,
city: o.city,
quality: o.quality,
sellPrice: null,
buyPrice: null,
sellSource: null,
buySource: null,
sellAgeHours: null,
buyAgeHours: null,
note: null,
};
if (o.sell_price != null) {
existing.sellPrice = o.sell_price;
existing.sellSource = 'manual';
existing.sellAgeHours = ageHours(o.updated_at);
}
if (o.buy_price != null) {
existing.buyPrice = o.buy_price;
existing.buySource = 'manual';
existing.buyAgeHours = ageHours(o.updated_at);
}
existing.note = o.note ?? existing.note;
byKey.set(key, existing);
}
return [...byKey.values()].sort((a, b) => a.city.localeCompare(b.city) || a.quality - b.quality);
}
export function setOverride(o: {
item: string;
city: string;
quality?: number;
sellPrice?: number | null;
buyPrice?: number | null;
note?: string | null;
}): void {
getDb()
.prepare(
`INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(item, city, quality) DO UPDATE SET
sell_price = excluded.sell_price,
buy_price = excluded.buy_price,
note = excluded.note,
updated_at = excluded.updated_at`,
)
.run(o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString());
}
export function deleteOverride(item: string, city: string, quality = 1): void {
getDb().prepare('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?').run(item, city, quality);
}
export function marketSummary() {
const db = getDb();
const n = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
return {
server: getAlbionServer(),
lastRefresh: getMeta('market_last_refresh'),
pricedRows: n('SELECT COUNT(*) AS n FROM market_prices'),
pricedItems: n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'),
overrides: n('SELECT COUNT(*) AS n FROM price_overrides'),
job: getRefreshJob(),
};
}

View file

@ -0,0 +1,90 @@
import type { FastifyInstance } from 'fastify';
import { getDb, getMeta, setMeta } from '../db/index.js';
import { CITIES, SERVER_HOSTS } from '../market/client.js';
import {
deleteOverride,
getPricesForItem,
getRefreshJob,
marketSummary,
setAlbionServer,
setOverride,
startRefresh,
} from '../market/service.js';
export async function marketRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/market/summary', async () => marketSummary());
app.get('/api/market/cities', async () => ({ cities: CITIES }));
app.get('/api/market/refresh/status', async () => getRefreshJob());
app.post('/api/market/refresh', async (req, reply) => {
const body = (req.body ?? {}) as {
minTier?: number;
maxTier?: number;
cities?: string[];
items?: string[];
};
const result = startRefresh(body);
if (!result.started) return reply.code(409).send({ error: result.reason });
return { started: true };
});
app.get('/api/market/prices/:item', async (req) => {
const { item } = req.params as { item: string };
return { prices: getPricesForItem(item) };
});
app.put('/api/market/override', async (req, reply) => {
const body = req.body as {
item?: string;
city?: string;
quality?: number;
sellPrice?: number | null;
buyPrice?: number | null;
note?: string | null;
};
if (!body?.item || !body?.city) return reply.code(400).send({ error: 'item and city are required' });
setOverride({
item: body.item,
city: body.city,
quality: body.quality,
sellPrice: body.sellPrice,
buyPrice: body.buyPrice,
note: body.note,
});
return { ok: true };
});
app.delete('/api/market/override', async (req, reply) => {
const { item, city, quality } = req.query as { item?: string; city?: string; quality?: string };
if (!item || !city) return reply.code(400).send({ error: 'item and city are required' });
deleteOverride(item, city, quality ? Number(quality) : 1);
return { ok: true };
});
app.get('/api/settings', async () => ({
albionServer: marketSummary().server,
availableServers: Object.keys(SERVER_HOSTS),
proxyUrl: getMeta('proxy_url') ?? '',
}));
app.put('/api/settings', async (req, reply) => {
const { albionServer, proxyUrl } = (req.body ?? {}) as { albionServer?: string; proxyUrl?: string };
if (albionServer) {
try {
setAlbionServer(albionServer);
} catch (e) {
return reply.code(400).send({ error: String(e) });
}
}
if (proxyUrl !== undefined) {
if (proxyUrl === '') {
getDb().prepare("DELETE FROM meta WHERE key = 'proxy_url'").run();
} else {
setMeta('proxy_url', proxyUrl);
}
}
return { ok: true };
});
}

View file

@ -1,10 +1,14 @@
import { useState } from 'react';
import ItemsPage from './pages/ItemsPage';
import GameDataPage from './pages/GameDataPage';
import MarketPage from './pages/MarketPage';
import SettingsPage from './pages/SettingsPage';
const PAGES = [
{ id: 'market', label: 'Market Data', component: MarketPage },
{ id: 'items', label: 'Item Browser', component: ItemsPage },
{ id: 'gamedata', label: 'Game Data', component: GameDataPage },
{ id: 'settings', label: 'Settings', component: SettingsPage },
] as const;
export default function App() {

View file

@ -0,0 +1,254 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface Job {
status: 'idle' | 'running' | 'done' | 'error';
totalChunks: number;
doneChunks: number;
itemCount: number;
rowsUpserted: number;
error?: string;
}
interface Summary {
server: string;
lastRefresh: string | null;
pricedRows: number;
pricedItems: number;
overrides: number;
job: Job;
}
interface Price {
item: string;
city: string;
quality: number;
sellPrice: number | null;
buyPrice: number | null;
sellSource: 'api' | 'manual' | null;
buySource: 'api' | 'manual' | null;
sellAgeHours: number | null;
buyAgeHours: number | null;
}
function AgeBadge({ hours, source }: { hours: number | null; source: string | null }) {
if (source === 'manual') return <span className="tag" style={{ color: '#7fb3ff' }}>manual</span>;
if (hours == null) return <span className="tag">—</span>;
const label = hours < 1 ? `${Math.round(hours * 60)}m` : hours < 48 ? `${Math.round(hours)}h` : `${Math.round(hours / 24)}d`;
const color = hours < 6 ? '#7fd18a' : hours < 24 ? '#e0c069' : '#e08869';
return <span className="tag" style={{ color }}>{label} old</span>;
}
const fmt = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US'));
export default function MarketPage() {
const [summary, setSummary] = useState<Summary | null>(null);
const [minTier, setMinTier] = useState(2);
const [maxTier, setMaxTier] = useState(8);
const [q, setQ] = useState('');
const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]);
const [selected, setSelected] = useState<{ unique_name: string; name_en: string | null } | null>(null);
const [prices, setPrices] = useState<Price[]>([]);
const [editing, setEditing] = useState<string | null>(null); // "city|quality"
const [editSell, setEditSell] = useState('');
const [editBuy, setEditBuy] = useState('');
const pollRef = useRef<number | null>(null);
const loadSummary = useCallback(() => {
fetch('/api/market/summary').then((r) => r.json()).then(setSummary);
}, []);
useEffect(() => {
loadSummary();
}, [loadSummary]);
// poll while a refresh runs
useEffect(() => {
if (summary?.job.status === 'running' && pollRef.current == null) {
pollRef.current = window.setInterval(loadSummary, 2000);
}
if (summary?.job.status !== 'running' && pollRef.current != null) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => {
if (pollRef.current != null) clearInterval(pollRef.current);
pollRef.current = null;
};
}, [summary?.job.status, loadSummary]);
useEffect(() => {
if (!q.trim()) return setResults([]);
const t = setTimeout(() => {
fetch(`/api/items/search?q=${encodeURIComponent(q)}`)
.then((r) => r.json())
.then((d) => setResults(d.results ?? []));
}, 250);
return () => clearTimeout(t);
}, [q]);
const loadPrices = useCallback((item: string) => {
fetch(`/api/market/prices/${encodeURIComponent(item)}`)
.then((r) => r.json())
.then((d) => setPrices(d.prices ?? []));
}, []);
const select = (it: { unique_name: string; name_en: string | null }) => {
setSelected(it);
setResults([]);
setQ('');
loadPrices(it.unique_name);
};
const startRefresh = async () => {
await fetch('/api/market/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minTier, maxTier }),
});
loadSummary();
};
const saveOverride = async (p: Price) => {
await fetch('/api/market/override', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item: p.item,
city: p.city,
quality: p.quality,
sellPrice: editSell === '' ? null : Number(editSell),
buyPrice: editBuy === '' ? null : Number(editBuy),
}),
});
setEditing(null);
loadPrices(p.item);
loadSummary();
};
const clearOverride = async (p: Price) => {
await fetch(`/api/market/override?item=${encodeURIComponent(p.item)}&city=${encodeURIComponent(p.city)}&quality=${p.quality}`, {
method: 'DELETE',
});
setEditing(null);
loadPrices(p.item);
loadSummary();
};
const job = summary?.job;
return (
<div>
<h2>Market Data</h2>
<div className="card">
<p>
Server: <strong>{summary?.server ?? '…'}</strong> · {summary?.pricedItems ?? 0} items priced (
{summary?.pricedRows ?? 0} rows) · {summary?.overrides ?? 0} manual overrides · last refresh:{' '}
{summary?.lastRefresh ? new Date(summary.lastRefresh).toLocaleString() : 'never'}
</p>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<label>
Tier <select value={minTier} onChange={(e) => setMinTier(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label>
to <select value={maxTier} onChange={(e) => setMaxTier(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}>
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
</button>
{job?.status === 'running' && (
<span className="muted">
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
</span>
)}
{job?.status === 'done' && <span className="muted">Last run stored {job.rowsUpserted} price rows.</span>}
</div>
{job?.status === 'error' && (
<p style={{ color: '#e08869' }}>
{job.error}
</p>
)}
</div>
<div className="card">
<h3>Price lookup</h3>
<input
type="text"
placeholder="Search an item to see its prices…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{results.length > 0 && (
<table style={{ marginTop: 10 }}>
<tbody>
{results.slice(0, 10).map((r) => (
<tr key={r.unique_name} className="clickable" onClick={() => select(r)}>
<td>{r.name_en ?? r.unique_name}</td>
<td className="muted">{r.unique_name}</td>
</tr>
))}
</tbody>
</table>
)}
{selected && (
<>
<h4>
{selected.name_en ?? selected.unique_name} <span className="tag">{selected.unique_name}</span>
</h4>
{prices.length === 0 && (
<p className="muted">No price data yet — run a price download above, or add a manual price.</p>
)}
<table>
<thead>
<tr>
<th>City</th><th>Q</th><th>Sell (min)</th><th></th><th>Buy (max)</th><th></th><th></th>
</tr>
</thead>
<tbody>
{prices.map((p) => {
const key = `${p.city}|${p.quality}`;
return (
<tr key={key}>
<td>{p.city}</td>
<td>{p.quality}</td>
<td>{fmt(p.sellPrice)}</td>
<td><AgeBadge hours={p.sellAgeHours} source={p.sellSource} /></td>
<td>{fmt(p.buyPrice)}</td>
<td><AgeBadge hours={p.buyAgeHours} source={p.buySource} /></td>
<td>
{editing === key ? (
<span style={{ display: 'inline-flex', gap: 6 }}>
<input type="number" placeholder="sell" value={editSell} style={{ width: 90 }}
onChange={(e) => setEditSell(e.target.value)} />
<input type="number" placeholder="buy" value={editBuy} style={{ width: 90 }}
onChange={(e) => setEditBuy(e.target.value)} />
<button className="primary" onClick={() => saveOverride(p)}>Save</button>
<button onClick={() => clearOverride(p)}>Clear</button>
<button onClick={() => setEditing(null)}>✕</button>
</span>
) : (
<button onClick={() => {
setEditing(key);
setEditSell(p.sellSource === 'manual' && p.sellPrice != null ? String(p.sellPrice) : '');
setEditBuy(p.buySource === 'manual' && p.buyPrice != null ? String(p.buyPrice) : '');
}}>
Edit
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
interface Settings {
albionServer: string;
availableServers: string[];
proxyUrl: string;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [proxyUrl, setProxyUrl] = useState('');
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((s: Settings) => {
setSettings(s);
setProxyUrl(s.proxyUrl);
});
}, []);
const save = async (patch: Partial<{ albionServer: string; proxyUrl: string }>) => {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const s = await fetch('/api/settings').then((r) => r.json());
setSettings(s);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
if (!settings) return <p className="muted">Loading…</p>;
return (
<div>
<h2>Settings</h2>
<div className="card">
<h3>Albion server</h3>
<p className="muted">Which game server's market prices to download.</p>
<select value={settings.albionServer} onChange={(e) => save({ albionServer: e.target.value })}>
{settings.availableServers.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="card">
<h3>Proxy for market data (optional)</h3>
<p className="muted">
The market data API (albion-online-data.com) is blocked in some countries. If your VPN covers the whole
system, leave this empty. If it is browser-only, enter an HTTP proxy URL here (e.g.
http://127.0.0.1:8080) and market downloads will go through it.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="http://host:port (empty = direct connection)"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
/>
<button className="primary" onClick={() => save({ proxyUrl })}>Save</button>
</div>
{saved && <p style={{ color: '#7fd18a' }}>Saved.</p>}
</div>
</div>
);
}

View file

@ -74,6 +74,17 @@ button.primary {
}
button.primary:disabled { opacity: 0.5; cursor: default; }
button:not(.primary) {
background: #2b323e;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
font-size: 13px;
}
button:not(.primary):hover { background: #333c4b; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; font-size: 13px; }