diff --git a/package-lock.json b/package-lock.json index 34a75cd..b8bacc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index aff25f7..25b8278 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 25a9d7e..f2fd928 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -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; diff --git a/server/src/index.ts b/server/src/index.ts index d13c8a4..980d4d3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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}`); diff --git a/server/src/market/client.ts b/server/src/market/client.ts new file mode 100644 index 0000000..7be877e --- /dev/null +++ b/server/src/market/client.ts @@ -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 }> { + 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 = { + 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 { + 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 { + 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; +} diff --git a/server/src/market/service.ts b/server/src/market/service.ts new file mode 100644 index 0000000..5474b36 --- /dev/null +++ b/server/src/market/service.ts @@ -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(); + 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(), + }; +} diff --git a/server/src/routes/market.ts b/server/src/routes/market.ts new file mode 100644 index 0000000..d0bacce --- /dev/null +++ b/server/src/routes/market.ts @@ -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 { + 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 }; + }); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 27c23f1..1e8f55d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { diff --git a/web/src/pages/MarketPage.tsx b/web/src/pages/MarketPage.tsx new file mode 100644 index 0000000..8212b9f --- /dev/null +++ b/web/src/pages/MarketPage.tsx @@ -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 manual; + if (hours == null) return —; + 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 {label} old; +} + +const fmt = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US')); + +export default function MarketPage() { + const [summary, setSummary] = useState(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([]); + const [editing, setEditing] = useState(null); // "city|quality" + const [editSell, setEditSell] = useState(''); + const [editBuy, setEditBuy] = useState(''); + const pollRef = useRef(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 ( +
+

Market Data

+ +
+

+ Server: {summary?.server ?? '…'} · {summary?.pricedItems ?? 0} items priced ( + {summary?.pricedRows ?? 0} rows) · {summary?.overrides ?? 0} manual overrides · last refresh:{' '} + {summary?.lastRefresh ? new Date(summary.lastRefresh).toLocaleString() : 'never'} +

+
+ + + + {job?.status === 'running' && ( + + {job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items) + + )} + {job?.status === 'done' && Last run stored {job.rowsUpserted} price rows.} +
+ {job?.status === 'error' && ( +

+ {job.error} +

+ )} +
+ +
+

Price lookup

+ setQ(e.target.value)} + /> + {results.length > 0 && ( + + + {results.slice(0, 10).map((r) => ( + select(r)}> + + + + ))} + +
{r.name_en ?? r.unique_name}{r.unique_name}
+ )} + + {selected && ( + <> +

+ {selected.name_en ?? selected.unique_name} {selected.unique_name} +

+ {prices.length === 0 && ( +

No price data yet — run a price download above, or add a manual price.

+ )} + + + + + + + + {prices.map((p) => { + const key = `${p.city}|${p.quality}`; + return ( + + + + + + + + + + ); + })} + +
CityQSell (min)Buy (max)
{p.city}{p.quality}{fmt(p.sellPrice)}{fmt(p.buyPrice)} + {editing === key ? ( + + setEditSell(e.target.value)} /> + setEditBuy(e.target.value)} /> + + + + + ) : ( + + )} +
+ + )} +
+
+ ); +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..bbd15e7 --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -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(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

Loading…

; + + return ( +
+

Settings

+
+

Albion server

+

Which game server's market prices to download.

+ +
+
+

Proxy for market data (optional)

+

+ 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. +

+
+ setProxyUrl(e.target.value)} + /> + +
+ {saved &&

Saved.

} +
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 3c5dd3f..888fda7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; }