From 3a12bd7de386649d8e190e0633ffd24ec48a46c4 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 21:33:36 -0500 Subject: [PATCH] feat(system-builder): the FEATURES page, the bank (4b1c2) components/BankSettings.tsx, opened from BANK on FEATURES (approved mockup builder-words-features, 2026-10-06): each currency's name, counted in (whole / decimals with symbol, before or after, places and mark / coins with worth, the smallest fixed at 1), READS AS, debt and below-zero switches, and an icon: none, one of the five, or uploaded (systemsApi.uploadIcon, POST /api/systems/currency-icons). The first is the main one; MAKE MAIN, REMOVE (extras only, asked first), + CURRENCY up to 8. Celebrations with whale status written as money (parseAmount) in the main currency. FeaturesPage passes the builder's api through for uploads. --- CHANGELOG.md | 8 + frontend/src/components/BankSettings.tsx | 240 ++++++++++++++++++ frontend/src/components/BuilderScreen.tsx | 2 +- frontend/src/components/FeaturesPage.tsx | 13 +- .../__tests__/BankSettings.test.tsx | 155 +++++++++++ .../__tests__/FeaturesPage.test.tsx | 11 +- .../src/sheets/__tests__/systemsApi.test.ts | 21 ++ frontend/src/sheets/systemsApi.ts | 17 ++ 8 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/BankSettings.tsx create mode 100644 frontend/src/components/__tests__/BankSettings.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee374e4..53ee948c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The builder's FEATURES page: the bank.** BANK opens out to the system's currencies and its + celebrations. Each currency is counted in whole numbers, decimals (with its symbol before or + after, and a point or a comma) or coins (each with its worth), and shows how an amount will read. + Each has its own debt and below-zero switches, and an icon: one of the five, or one you upload. + The first currency is the main one, the balance every bank account already holds. Another can be + made main, and an extra one is removed only after asking. Whale status is written the way people + write money ("500 gp") and read back in the main currency. + - **The builder's FEATURES page: parts and shops.** Every part of the app as a switch with a line saying what it does, all on unless turned off. XP awards, death saves, luck and PDF import show as off and greyed, since a custom system can't use them yet. SHOPS opens out to the app's shop diff --git a/frontend/src/components/BankSettings.tsx b/frontend/src/components/BankSettings.tsx new file mode 100644 index 00000000..83947fae --- /dev/null +++ b/frontend/src/components/BankSettings.tsx @@ -0,0 +1,240 @@ +import React, { useRef, useState } from 'react'; +import type { Definition, systemsApi } from '../sheets/systemsApi'; +import { BUILT_IN_ICONS, formatAmount, parseAmount } from '../sheets/currencies'; +import { amountProblem, amountExample } from '../sheets/moneyText'; +import { + currencyList, countedIn, shapeCurrency, withNewCurrency, withoutCurrency, withMainCurrency, withCurrency, withCountedIn, withCoins, + bankSettings, withBank, CURRENCY_LIMITS, type CountedIn, type StoredCurrency, +} from '../sheets/wordsFeatures'; +import { CurrencyIcon } from './BankWindows'; + +// The bank's settings on the builder's FEATURES page (4b1c2): the system's currencies and its +// celebrations (approved mockup docs/mockups/builder-words-features.html, 2026-10-06). Each currency +// is counted in whole numbers, decimals or coins, with its debt and below-zero switches and its +// icon: the five CURRENCY_ICON has always offered, or one uploaded (the user, same day). The first +// is the main one, the balance every bank account already holds, so it is never removed outright: +// another is made main first. What it reads and writes is sheets/wordsFeatures.ts; every change +// goes through the builder's `edit`, which autosaves it. + +interface Props { + definition: Definition; + edit: (next: Definition) => void; + api?: ReturnType; +} + +/** How a sample amount reads, for each currency's READS AS line. */ +const SAMPLE = 123456; +const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85 }; +const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' }; +const field: React.CSSProperties = { + background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px', minWidth: 0, +}; +const seg = (on: boolean): React.CSSProperties => ({ + background: on ? 'var(--green)' : 'none', color: on ? 'var(--black)' : 'var(--green)', border: 0, fontFamily: 'monospace', + fontSize: 11, letterSpacing: 1, padding: '4px 10px', cursor: 'pointer', +}); + +function Segments({ label, value, options, onChange }: { label: string; value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( + + {options.map(([v, text]) => )} + + ); +} + +function CurrencyEditor({ definition, edit, api, index, say }: Props & { index: number; say: (text: string | null) => void }) { + const c = currencyList(definition)[index]; + const main = index === 0; + const how = countedIn(c); + const set = (patch: Partial) => edit(withCurrency(definition, index, patch)); + const [confirmRemove, setConfirmRemove] = useState(false); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const upload = async (file: File | undefined) => { + if (!file || !api) return; + setUploading(true); + const r = await api.uploadIcon(file); + setUploading(false); + if (!r.ok) { say(r.error); return; } + say(null); + set({ icon: r.value.icon }); + }; + + return ( +
+
+ + set({ name: e.target.value })} /> + {main ? 'MAIN · THE BANK BALANCE' : 'EXTRA'} + + + {!main && } + {!main && } + +
+ {confirmRemove && ( +
+ Remove {c.name}? Players' balances in it are kept but hidden, and its catalogues go back to the main currency. + + + + +
+ )} + +
+ COUNTED IN + label={`${c.name} counted in`} value={how} options={[['whole', 'WHOLE NUMBERS'], ['decimals', 'DECIMALS'], ['coins', 'COINS']]} + onChange={(v) => edit(withCountedIn(definition, index, v))} /> +
+ + {how !== 'coins' && ( +
+ + {c.symbol && ( + label={`${c.name} symbol`} value={c.symbolAfter ? 'after' : 'before'} options={[['before', 'BEFORE'], ['after', 'AFTER']]} + onChange={(v) => set({ symbolAfter: v === 'after' })} /> + )} + {how === 'decimals' && <> + + label={`${c.name} decimal mark`} value={c.decimalMark === ',' ? ',' : '.'} options={[['.', 'POINT'], [',', 'COMMA']]} + onChange={(v) => set({ decimalMark: v })} /> + } + {how === 'whole' && ( + + )} +
+ )} + + {how === 'coins' && ( +
+ {(c.denominations ?? []).map((d, j, all) => { + const smallest = d.value === Math.min(...all.map((x) => x.value)); + return ( +
+ edit(withCoins(definition, index, { edit: j, patch: { name: e.target.value } }))} /> + edit(withCoins(definition, index, { edit: j, patch: { short: e.target.value } }))} /> + {smallest + ? worth 1 (the smallest) + : } + +
+ ); + })} +
+
+ )} + +
+ ICON + + + {BUILT_IN_ICONS.map((icon) => ( + + ))} + {c.icon && !(BUILT_IN_ICONS as readonly string[]).includes(c.icon) && ( + + )} + {api && <> + + { upload(e.target.files?.[0]); e.target.value = ''; }} /> + } + +
+ +
+ READS AS + {formatAmount(shapeCurrency(c), SAMPLE)} +
+
+ + +
+
+ ); +} + +/** The whale threshold, written as people write money and read back in the main currency. */ +function Whale({ definition, edit }: Props) { + const main = currencyList(definition)[0]; + const shaped = main ? shapeCurrency(main) : null; + const { whale } = bankSettings(definition); + const [text, setText] = useState(whale !== null && shaped ? formatAmount(shaped, whale) : ''); + const [problem, setProblem] = useState(null); + if (!shaped) return Whale status needs a currency of the system's own: add one above.; + return ( +
+ + {problem && {problem}} +
+ ); +} + +export function BankSettings({ definition, edit, api }: Props) { + const currencies = currencyList(definition); + const { celebrations } = bankSettings(definition); + const [message, setMessage] = useState(null); + const box: React.CSSProperties = { border: '1px solid var(--dark-green)', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }; + return <> +
+

CURRENCIES

+ + {currencies.length + ? 'The first is the main one: the balance every bank account already holds. They don\'t convert into each other.' + : 'None yet: the bank keeps the app\'s own money. The first you add becomes the main one, and every balance already held is in it.'} + + {currencies.map((c, i) => )} + {message && {message}} +
+ + {currencies.length} of {CURRENCY_LIMITS.currencies}. +
+
+
+

CELEBRATIONS

+ + {celebrations && } +
+ ; +} diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index dc45c4eb..6cbab36a 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -319,7 +319,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o setStatus({ text, bad })} /> )} {system && definition && page === 'words' && } - {system && definition && page === 'features' && } + {system && definition && page === 'features' && } {system && !['problems', 'systems', 'setup', 'words', 'features'].includes(page) && (

{current.label}

diff --git a/frontend/src/components/FeaturesPage.tsx b/frontend/src/components/FeaturesPage.tsx index 0d2e65c0..56dbdb7a 100644 --- a/frontend/src/components/FeaturesPage.tsx +++ b/frontend/src/components/FeaturesPage.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; -import type { Definition } from '../sheets/systemsApi'; +import type { Definition, systemsApi } from '../sheets/systemsApi'; +import { BankSettings } from './BankSettings'; import { PART_ROWS, partIsOn, withPart, SHOP_TYPES, OTHER_TYPES, CATALOGUE_ROWS, BUILDING_NAME_LIMIT, buildingSetting, withBuilding, cataloguePartOff, currencyList, type BuildingKind, @@ -16,6 +17,8 @@ import { interface Props { definition: Definition; edit: (next: Definition) => void; + /** For uploading a currency's icon (BankSettings). */ + api?: ReturnType; } const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; @@ -106,10 +109,10 @@ function ShopsSettings({ definition, edit }: Props) { ; } -export function FeaturesPage({ definition, edit }: Props) { +export function FeaturesPage({ definition, edit, api }: Props) { const [open, setOpen] = useState>({}); - // A part's settings, where it has any. The bank's come with 4b1c2. - const settings: Partial React.ReactElement>> = { shops: ShopsSettings }; + // A part's settings, where it has any. + const settings: Partial React.ReactElement>> = { bank: BankSettings, shops: ShopsSettings }; return (
@@ -136,7 +139,7 @@ export function FeaturesPage({ definition, edit }: Props) { {p.unused ? `${p.what} Not used by custom systems yet.` : p.what}
- {isOpen &&
} + {isOpen &&
}
); })} diff --git a/frontend/src/components/__tests__/BankSettings.test.tsx b/frontend/src/components/__tests__/BankSettings.test.tsx new file mode 100644 index 00000000..fe4045ed --- /dev/null +++ b/frontend/src/components/__tests__/BankSettings.test.tsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../../assets/Credits.png', () => ({ default: 'credits.png' })); + +import { BankSettings } from '../BankSettings'; +import { systemsApi, type Definition } from '../../sheets/systemsApi'; + +/** + * The bank's settings on FEATURES (4b1c2). Approved mockup builder-words-features (2026-10-06): + * currencies counted in whole numbers, decimals or coins, each with its switches and icon (the five, + * or uploaded); the main one is the bank balance; celebrations with a whale threshold in it. + */ + +const UPLOADED = `/uploads/currency_icons/${'e'.repeat(64)}.png`; +afterEach(() => cleanup()); + +const open = (start: Definition = { format: 1, name: 'Hearth' }, fetcher?: typeof fetch) => { + const edits: Definition[] = []; + const Harness = () => { + const [def, setDef] = useState(start); + return { edits.push(next); setDef(next); }} />; + }; + render(); + return { edits, last: () => edits[edits.length - 1] }; +}; +const gold: Definition = { format: 1, name: 'Hearth', currencies: [{ id: 'gold', name: 'Gold' }] }; +const currency = (id: string) => screen.getByTestId(`currency-${id}`); +const readsAs = (id: string) => screen.getByTestId(`reads-as-${id}`).textContent; + +describe('currencies', () => { + it('starts with the app\'s money, and the first one added is the main one', async () => { + const { last } = open(); + expect(screen.getByText(/None yet: the bank keeps the app's own money/)).toBeTruthy(); + await userEvent.click(screen.getByText('+ CURRENCY')); + expect(last().currencies).toEqual([{ id: 'new_currency', name: 'NEW CURRENCY' }]); + expect(within(currency('new_currency')).getByText('MAIN · THE BANK BALANCE')).toBeTruthy(); + expect(within(currency('new_currency')).queryByText('REMOVE')).toBeNull(); + expect(within(currency('new_currency')).queryByText('MAKE MAIN')).toBeNull(); + }); + + it('renames one, and reads an amount in whole numbers, decimals or coins', async () => { + const { last } = open(gold); + expect(readsAs('gold')).toBe('123,456 Gold'); + await userEvent.click(within(currency('gold')).getByText('DECIMALS')); + await userEvent.type(within(currency('gold')).getByPlaceholderText('none'), '$'); + expect(readsAs('gold')).toBe('$1,234.56'); + await userEvent.click(within(currency('gold')).getByText('AFTER')); + await userEvent.click(within(currency('gold')).getByText('COMMA')); + expect(readsAs('gold')).toBe('1.234,56 $'); + fireEvent.change(within(currency('gold')).getByRole('spinbutton'), { target: { value: '9' } }); + expect(last().currencies).toEqual([{ id: 'gold', name: 'Gold', symbol: '$', symbolAfter: true, decimals: 4, decimalMark: ',' }]); + await userEvent.click(within(currency('gold')).getByText('COINS')); + await userEvent.click(within(currency('gold')).getByText('+ COIN')); + await userEvent.type(within(currency('gold')).getByLabelText('Coin 1 short'), 'gp'); + await userEvent.type(within(currency('gold')).getByLabelText('Coin 2 short'), 'cp'); + expect(readsAs('gold')).toBe('12345 gp 6 cp'); + fireEvent.change(within(currency('gold')).getByLabelText('Coin 1 worth'), { target: { value: '100' } }); + expect(readsAs('gold')).toBe('1234 gp 56 cp'); + expect((within(currency('gold')).getByLabelText('Remove coin Gold') as HTMLButtonElement).disabled).toBe(true); + const name = within(currency('gold')).getByLabelText('Currency name'); + await userEvent.clear(name); + await userEvent.type(name, 'Crowns'); + expect(last().currencies![0].name).toBe('Crowns'); + }); + + it('turns debt and a balance below zero on and off', async () => { + const { last } = open(gold); + await userEvent.click(within(currency('gold')).getByLabelText(/Players can borrow it/)); + await userEvent.click(within(currency('gold')).getByLabelText(/A balance can go below zero/)); + expect(last().currencies).toEqual([{ id: 'gold', name: 'Gold', debt: true, negative: true }]); + await userEvent.click(within(currency('gold')).getByLabelText(/Players can borrow it/)); + expect(last().currencies).toEqual([{ id: 'gold', name: 'Gold', negative: true }]); + }); + + it('makes an extra currency the main one, and removes one only after asking', async () => { + const { last } = open({ ...gold, currencies: [{ id: 'gold', name: 'Gold' }, { id: 'favor', name: 'Favor' }] }); + await userEvent.click(within(currency('favor')).getByText('MAKE MAIN')); + expect(last().currencies!.map((c) => c.id)).toEqual(['favor', 'gold']); + await userEvent.click(within(currency('gold')).getByText('REMOVE')); + const ask = screen.getByRole('alertdialog', { name: 'Remove Gold' }); + expect(ask.textContent).toContain('Players\' balances in it are kept but hidden'); + await userEvent.click(within(ask).getByText('KEEP IT')); + expect(last().currencies!.map((c) => c.id)).toEqual(['favor', 'gold']); + await userEvent.click(within(currency('gold')).getByText('REMOVE')); + await userEvent.click(within(screen.getByRole('alertdialog')).getByText('REMOVE')); + expect(last().currencies!.map((c) => c.id)).toEqual(['favor']); + }); + + it('stops at eight', async () => { + const eight = Array.from({ length: 8 }, (_, i) => ({ id: `c${i}`, name: `C${i}` })); + open({ ...gold, currencies: eight }); + expect((screen.getByText('+ CURRENCY') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('8 of 8.')).toBeTruthy(); + }); +}); + +describe('icons', () => { + it('picks one of the five, or none', async () => { + const { last } = open(gold); + await userEvent.click(within(currency('gold')).getByLabelText('🪙 for Gold')); + expect(last().currencies).toEqual([{ id: 'gold', name: 'Gold', icon: '🪙' }]); + expect(within(currency('gold')).getByLabelText('🪙 for Gold').getAttribute('aria-pressed')).toBe('true'); + await userEvent.click(within(currency('gold')).getByText('NONE')); + expect(last().currencies).toEqual([{ id: 'gold', name: 'Gold' }]); + }); + + it('uploads one and uses it, or says why not', async () => { + let answer: { status: number; body: unknown } = { status: 200, body: { icon: UPLOADED } }; + const fetcher = vi.fn(async () => ({ ok: answer.status < 300, status: answer.status, json: async () => answer.body }) as unknown as Response); + const { last } = open(gold, fetcher as typeof fetch); + await userEvent.upload(within(currency('gold')).getByLabelText('Upload an icon for Gold'), new File([''], 'coin.svg', { type: 'image/svg+xml' })); + await waitFor(() => expect(last().currencies).toEqual([{ id: 'gold', name: 'Gold', icon: UPLOADED }])); + expect(fetcher.mock.calls[0][0]).toBe('/api/systems/currency-icons'); + expect(within(currency('gold')).getByLabelText('Uploaded icon').querySelector('img')!.getAttribute('src')).toBe(UPLOADED); + answer = { status: 413, body: { error: '"huge.png" is over the 0.25MB limit.' } }; + await userEvent.upload(within(currency('gold')).getByLabelText('Upload an icon for Gold'), new File(['x'], 'huge.png', { type: 'image/png' })); + expect((await screen.findByRole('alert')).textContent).toBe('"huge.png" is over the 0.25MB limit.'); + }); + + it('has no UPLOAD where it can\'t send one', () => { + open(gold); + expect(within(currency('gold')).queryByText('UPLOAD')).toBeNull(); + }); +}); + +describe('celebrations', () => { + it('turns them on and sets whale status in the main currency, written as money', async () => { + const { last } = open({ ...gold, currencies: [{ id: 'gold', name: 'Gold', denominations: [{ id: 'gp', name: 'Gold', short: 'gp', value: 100 }, { id: 'cp', name: 'Copper', short: 'cp', value: 1 }] }] }); + expect(screen.queryByText('WHALE STATUS AT')).toBeNull(); + await userEvent.click(screen.getByLabelText(/The bank's easter eggs/)); + expect(last().bank).toEqual({ celebrations: true }); + expect(screen.getByText('Empty: whale status never fires.')).toBeTruthy(); + const whale = screen.getByLabelText(/WHALE STATUS AT/); + await userEvent.type(whale, '500 gp'); + expect(last().bank).toEqual({ celebrations: true, whale: 50000 }); + expect(screen.getByText('= 500 gp in Gold')).toBeTruthy(); + await userEvent.clear(whale); + expect(last().bank).toEqual({ celebrations: true }); + await userEvent.type(whale, 'lots'); + expect(screen.getByRole('alert')).toBeTruthy(); + expect(last().bank).toEqual({ celebrations: true }); + await userEvent.click(screen.getByLabelText(/The bank's easter eggs/)); + expect('bank' in last()).toBe(false); + }); + + it('needs a currency of the system\'s own for whale status', async () => { + open(); + await userEvent.click(screen.getByLabelText(/The bank's easter eggs/)); + expect(screen.getByText('Whale status needs a currency of the system\'s own: add one above.')).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/__tests__/FeaturesPage.test.tsx b/frontend/src/components/__tests__/FeaturesPage.test.tsx index e141fbef..35187183 100644 --- a/frontend/src/components/__tests__/FeaturesPage.test.tsx +++ b/frontend/src/components/__tests__/FeaturesPage.test.tsx @@ -57,7 +57,7 @@ describe('the parts', () => { it('opens SHOPS\' settings only while shops are on', async () => { open(); - expect(within(part('bank')).queryByText(/SETTINGS/)).toBeNull(); + expect(within(part('vehicles')).queryByText(/SETTINGS/)).toBeNull(); await openShops(); expect(screen.getByRole('region', { name: 'Shop buildings' })).toBeTruthy(); await userEvent.click(within(part('shops')).getByText(/SETTINGS/)); @@ -69,6 +69,15 @@ describe('the parts', () => { }); }); +describe('BANK', () => { + it('opens out to the currencies and celebrations', async () => { + open(); + await userEvent.click(within(part('bank')).getByText(/SETTINGS/)); + expect(screen.getByRole('region', { name: 'Currencies' })).toBeTruthy(); + expect(screen.getByRole('region', { name: 'Celebrations' })).toBeTruthy(); + }); +}); + describe('SHOPS', () => { it('renames a building type, blank going back to the app\'s', async () => { const { last } = open(); diff --git a/frontend/src/sheets/__tests__/systemsApi.test.ts b/frontend/src/sheets/__tests__/systemsApi.test.ts index df8d1b9d..00847f25 100644 --- a/frontend/src/sheets/__tests__/systemsApi.test.ts +++ b/frontend/src/sheets/__tests__/systemsApi.test.ts @@ -77,6 +77,27 @@ describe('the requests', () => { }); }); +describe('uploading a currency icon', () => { + it('sends the file as a form with the token, and answers with its address', async () => { + const f = server(200, { icon: '/uploads/currency_icons/abc.png' }); + const file = new File(['x'], 'coin.png', { type: 'image/png' }); + expect(await systemsApi('tok', f).uploadIcon(file)).toEqual({ ok: true, value: { icon: '/uploads/currency_icons/abc.png' } }); + const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('/api/systems/currency-icons'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ Authorization: 'Bearer tok' }); + expect((init.body as FormData).get('icon')).toBe(file); + }); + + it('passes on a refusal, a garbled answer, and an unreachable server', async () => { + const file = new File(['x'], 'coin.gif'); + expect(await systemsApi('tok', server(400, { error: 'Only .png, .webp, .svg' })).uploadIcon(file)).toEqual({ ok: false, status: 400, error: 'Only .png, .webp, .svg' }); + expect(await systemsApi('tok', server(200, { nope: 1 })).uploadIcon(file)).toEqual({ ok: false, status: 200, error: 'The icon could not be uploaded.' }); + const down = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + expect(await systemsApi('tok', down as never).uploadIcon(file)).toEqual({ ok: false, status: 0, error: 'Could not reach the server.' }); + }); +}); + describe('exporting', () => { it('fetches the file with the token and names it as the server does', async () => { const f = server(200, '{"citysys":1}', { 'Content-Disposition': 'attachment; filename="vault-knights.citysys"' }); diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts index 95558cb5..bae03268 100644 --- a/frontend/src/sheets/systemsApi.ts +++ b/frontend/src/sheets/systemsApi.ts @@ -70,6 +70,23 @@ export const systemsApi = (token: string, fetcher: typeof fetch = fetch) => { preview: (file: string) => call(fetcher, token, '/api/systems/install/preview', 'POST', { file }), install: (file: string, mode: InstallMode, replaceChanges = false) => call(fetcher, token, '/api/systems/install', 'POST', { file, mode, ...(replaceChanges ? { replaceChanges: true } : {}) }), + /** + * A currency icon image (PNG, WebP or SVG, a quarter of a megabyte at most), stored by the + * server under its content hash: the address a currency's `icon` then names. + */ + uploadIcon: async (file: File): Promise> => { + const form = new FormData(); + form.append('icon', file); + let res: Response; + try { + res = await fetcher('/api/systems/currency-icons', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form }); + } catch { + return { ok: false, error: UNREACHABLE, status: 0 }; + } + const json = await res.json().catch(() => null); + if (res.ok && json && typeof json.icon === 'string') return { ok: true, value: { icon: json.icon } }; + return { ok: false, status: res.status, error: json && typeof json.error === 'string' ? json.error : 'The icon could not be uploaded.' }; + }, /** A published system as its .citysys file: its name and text, for the window to save. */ exportFile: async (systemId: string): Promise> => { let res: Response;