TypeScript, in layers, with a strict linter

The client was one main.js, one preload.js, three files in lib/ and one renderer
script. It is now a typed application whose imports point inward: domain (models,
ports, errors) knows nothing about Electron, Node or Python; application orchestrates
it through those ports; infrastructure holds the adapters — the Python CLI, HTTP, the
filesystem, Electron itself — and main, preload and renderer sit on top as hosts.

STRUCTURE.md is the map, and the deliverable as much as the code is: every layer, every
pattern in use (ports and adapters, repository vs gateway, service, DTO and mapper,
composition root, controller and router, single flight, observer streams, state store
with unidirectional flow, passive view, coded error hierarchy, frozen constant tables,
untrusted-data readers) and the naming rules — files, classes, and a verb vocabulary
for methods where find/require/read/list/apply/render/handle each state a contract.

Two properties fell out of the move, and they are why it was worth doing:

  - The catalog can be driven with no window and no Electron at all. The smoke test
    assembles the same services against the same ports in a plain Node process; it used
    to be a script that reimplemented the bridge.
  - The window never receives a filesystem path. A title crosses the bridge without
    one, and launching is asked for by name, resolved in the main process from the
    store's own state. Verified with a fake launcher: an unknown name answers false, a
    native title resolves to its menu entry, a hosted one to its catalog URL.

Types are mandatory, including where inference would manage: explicit return,
parameter and property types, strict plus noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride and noPropertyAccessFromIndexSignature,
typescript-eslint strictTypeChecked and stylisticTypeChecked, exhaustive switches, no
any, no non-null assertions, and no casts on foreign data — engine stdout and the
registry go through readers that turn unknown into typed values. naming-convention
enforces the patterns rather than trusting them.

Two rule conflicts had to be decided rather than papered over. typedef and
no-inferrable-types disagree about `fallback: string = ''`: the annotation wins, since a
signature states its types. erasableSyntaxOnly is off, because it forbids constructor
parameter properties, which are how dependencies are declared here.

The preload and the renderer are bundled by esbuild into one file each: a sandboxed
preload may not require its own modules, and a module script over file:// is blocked by
the page's own origin rules. tsc compiles the rest. The package ships build/** and
package.json — 111 entries, no sources, no toolchain.

New targets: build, typecheck, lint, lint-fix, and check — typecheck, lint, then both
test suites, cheapest failure first. Every script that runs the app builds first, so a
stale bundle cannot be tested.

Nothing about the window changed: same side menu, same categories, same switcher, same
two languages. make check is clean, both test suites pass with one store and with two,
the packaged 1.3.0 bundle drives the real store, and the window was photographed before
and after — the two are the same picture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:29:18 +02:00
co-authored by Claude Opus 5
parent a25acf6e35
commit 3d63c8a0b0
116 changed files with 6123 additions and 1704 deletions
+21
View File
@@ -0,0 +1,21 @@
import { BRIDGE_GLOBAL_NAME, type BridgeApi } from '../shared/contracts/BridgeApi'
declare global {
interface Window {
readonly storeApi?: BridgeApi
}
}
/**
* The bridge the preload published.
*
* Absent means the preload did not run, which is a packaging fault rather than a
* runtime condition — so it fails here, once, with a sentence that says what happened.
*/
export function requireBridge (): BridgeApi {
const bridge = window.storeApi
if (bridge === undefined) {
throw new Error(`window.${BRIDGE_GLOBAL_NAME} is missing — the preload script did not run`)
}
return bridge
}
+106
View File
@@ -0,0 +1,106 @@
import type { BridgeApi } from '../shared/contracts/BridgeApi'
import { requireBridge } from './BridgeAccess'
import { CatalogController } from './controllers/CatalogController'
import { EngineStreamController } from './controllers/EngineStreamController'
import { PreferencesController } from './controllers/PreferencesController'
import { StoreController } from './controllers/StoreController'
import { AppStore, type AppState } from './state/AppStore'
import { buildCategorySections, resolveFilter, type CategoryFilter } from './state/CategoryFilter'
import { CatalogGridView } from './views/CatalogGridView'
import { GameCardView } from './views/GameCardView'
import { GateView } from './views/GateView'
import { LogDrawerView } from './views/LogDrawerView'
import { SideMenuView } from './views/SideMenuView'
import { TopBarView } from './views/TopBarView'
/**
* The window, assembled.
*
* The flow is one direction only: a control calls a controller, the controller calls
* the bridge and writes to the store, and the store re-renders every view. No view
* reads another view, and nothing but the store decides what is on screen.
*/
export class RendererApplication {
private readonly store = new AppStore()
private readonly bridge: BridgeApi
private readonly log: LogDrawerView
private readonly gate: GateView
private readonly grid: CatalogGridView
private readonly topBar: TopBarView
private readonly sideMenu: SideMenuView
private readonly catalog: CatalogController
private readonly stores: StoreController
private readonly preferences: PreferencesController
private readonly streams: EngineStreamController
public constructor (bridge: BridgeApi = requireBridge()) {
this.bridge = bridge
this.log = new LogDrawerView({
onOpenFolder: (directory: string): void => { void this.bridge.openFolder(directory) }
})
this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) })
this.catalog = new CatalogController(this.bridge, this.store, this.log)
this.stores = new StoreController(this.bridge, this.store, this.gate, this.log, this.catalog)
this.preferences = new PreferencesController(this.bridge, this.store)
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
this.grid = new CatalogGridView(new GameCardView({
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
}))
this.topBar = new TopBarView({
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
})
this.sideMenu = new SideMenuView({
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
onAddStore: (): void => { void this.stores.offerStores() },
onSyncAll: (): void => { void this.catalog.syncGames([]) },
onRefresh: (): void => { void this.catalog.refresh() },
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
})
this.store.subscribe((state: AppState): void => { this.render(state) })
this.streams.subscribe()
}
/** Decides what the window is showing, then hands over to the views. */
public async start (): Promise<void> {
this.store.applyAppState(await this.bridge.readState())
const state = this.store.readState()
if (state.pythonVersion === null) {
this.stores.showMissingPythonGate()
return
}
if (state.currentStore === null) {
await this.stores.offerStores()
return
}
if (state.engine !== null && !state.engine.supported) {
this.stores.showOutdatedEngineGate()
return
}
this.gate.hide()
await this.catalog.refresh()
}
private render (state: AppState): void {
// The filter is corrected before anything is drawn from it, so the menu and the
// grid can never disagree about which category is active.
const corrected = resolveFilter(state.filter, buildCategorySections(state.games, state.messages))
if (corrected !== state.filter) {
this.store.applyFilter(corrected)
return
}
document.body.classList.toggle('nav-closed', !state.navigationOpen)
this.topBar.render(state)
this.sideMenu.render(state)
this.log.render(state)
if (this.gate.visible) this.grid.hide()
else this.grid.render(state)
}
}
@@ -0,0 +1,59 @@
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto'
import type { AppStore } from '../state/AppStore'
import type { LogDrawerView } from '../views/LogDrawerView'
/**
* The catalog operations, as the window performs them.
*
* Every one of them ends in a refresh: the engine is the authority on what is
* installed, so the window asks again rather than guessing what changed.
*/
export class CatalogController {
public constructor (
private readonly bridge: BridgeApi,
private readonly store: AppStore,
private readonly log: LogDrawerView
) {}
public async refresh (): Promise<void> {
try {
const listing: CatalogListingDto = await this.bridge.listGames()
this.store.applyCatalog(listing.games, listing.paths)
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
} catch (error: unknown) {
this.reportFailure(error)
}
}
public async syncGames (names: readonly string[]): Promise<void> {
try {
await this.bridge.syncGames(names)
} catch (error: unknown) {
this.reportFailure(error)
}
await this.refresh()
}
public async removeGame (name: string): Promise<void> {
try {
await this.bridge.removeGame(name)
} catch (error: unknown) {
this.reportFailure(error)
}
await this.refresh()
}
public async launchGame (name: string): Promise<void> {
try {
const launched = await this.bridge.launchGame(name)
if (!launched) this.log.appendLine(`${name}: ${this.store.readState().messages.failed}`)
} catch (error: unknown) {
this.reportFailure(error)
}
}
private reportFailure (error: unknown): void {
this.log.appendLine(error instanceof Error ? error.message : String(error))
}
}
@@ -0,0 +1,67 @@
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
import type { AppStore, SyncProgress } from '../state/AppStore'
import type { LogDrawerView } from '../views/LogDrawerView'
/**
* The engine's own voice: its log, its progress events, and whether it is busy.
*
* Subscribed once at boot. The counter is derived from the events rather than
* guessed: `plan` says how many titles there are, `begin` and `installed` move it.
*/
export class EngineStreamController {
private progress: SyncProgress | null = null
public constructor (
private readonly bridge: BridgeApi,
private readonly store: AppStore,
private readonly log: LogDrawerView
) {}
public subscribe (): void {
this.bridge.onLog((line: string): void => { this.log.appendLine(line) })
this.bridge.onBusyChanged((busy: boolean): void => {
if (!busy) this.progress = null
this.store.applyBusy(busy)
})
this.bridge.onSyncEvent((event: SyncEventDto): void => { this.handleEvent(event) })
}
private handleEvent (event: SyncEventDto): void {
const messages = this.store.readState().messages
switch (event.event) {
case 'plan':
this.progress = { total: event.count, done: 0, label: `0 ${messages.of} ${String(event.count)}` }
this.store.applyProgress(this.progress)
return
case 'begin': {
const current = this.progress
if (current === null) return
this.progress = {
...current,
label: `${String(current.done + 1)} ${messages.of} ${String(current.total)} · ${event.title}`
}
this.store.applyProgress(this.progress)
return
}
case 'installed': {
const current = this.progress
if (current !== null) {
this.progress = { ...current, done: current.done + 1 }
this.store.applyProgress(this.progress)
}
this.log.appendLine(`${event.title}${event.changed ? messages.installed : messages.upToDate}`)
return
}
case 'failed':
this.log.appendLine(`${event.name}: ${messages.failed}${event.error}`)
return
case 'removed':
this.log.appendLine(`${event.name}${messages.removed}`)
return
case 'pruned':
case 'finished':
return
}
}
}
@@ -0,0 +1,21 @@
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
import type { AppStore } from '../state/AppStore'
/** The two things the window remembers: its language and whether the menu is open. */
export class PreferencesController {
public constructor (
private readonly bridge: BridgeApi,
private readonly store: AppStore
) {}
public async selectLocale (candidate: string): Promise<void> {
const selection = await this.bridge.updateLocale(candidate)
this.store.applyMessages(selection.locale, selection.messages)
}
public async toggleNavigation (): Promise<void> {
const open = !this.store.readState().navigationOpen
this.store.applyNavigationOpen(open)
await this.bridge.updateNavOpen(open)
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
import type { AppStore } from '../state/AppStore'
import type { GateView } from '../views/GateView'
import type { LogDrawerView } from '../views/LogDrawerView'
import type { CatalogController } from './CatalogController'
/**
* Which store the window drives, and how one gets onto the machine.
*
* The gate screens live here because they are all the same decision seen from
* different angles: there is no store to show a catalog for, and this is what can be
* done about it.
*/
export class StoreController {
public constructor (
private readonly bridge: BridgeApi,
private readonly store: AppStore,
private readonly gate: GateView,
private readonly log: LogDrawerView,
private readonly catalog: CatalogController
) {}
/** Open another store that is already on this machine. */
public async selectStore (home: string): Promise<void> {
const messages = this.store.readState().messages
try {
const selection = await this.bridge.selectStore(home)
this.store.applySelectedStore(selection.store, selection.engine)
if (selection.engine !== null && !selection.engine.supported) {
this.showOutdatedEngineGate()
return
}
this.gate.hide()
await this.catalog.refresh()
} catch (error: unknown) {
this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Which stores exist is the site's answer, not this client's: the registry is asked
* for it, and its records carry the catalog and the config repository.
*/
public async offerStores (): Promise<void> {
const state = this.store.readState()
const messages = state.messages
const result = await this.bridge.listRegistryStores()
if (result.error !== null) {
this.gate.show({
title: messages.registryFailed,
body: `${result.sourceUrl}\n\n${result.error}`,
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
}, messages)
return
}
if (result.stores.length === 0) {
this.gate.show({
title: messages.setupTitle,
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
}, messages)
return
}
this.gate.show({
title: messages.setupTitle,
body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`,
action: {
label: messages.setupAction,
perform: (chosen: RegistryStoreDto | null): void => {
if (chosen !== null) void this.installStore(chosen)
}
},
choices: result.stores
}, messages)
}
public showOutdatedEngineGate (): void {
const state = this.store.readState()
const engineText = state.engine === null ? '' : state.engine.text
this.gate.show({
title: state.messages.oldEngineTitle,
body: `${state.messages.oldEngineBody}\n\n${engineText}${state.minimumEngineVersion}`,
action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } }
}, state.messages)
}
public showMissingPythonGate (): void {
const messages = this.store.readState().messages
this.gate.show({
title: messages.noPythonTitle,
body: messages.noPythonBody,
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
}, messages)
}
private async installStore (chosen: RegistryStoreDto): Promise<void> {
const messages = this.store.readState().messages
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
try {
await this.bridge.installStore(chosen)
this.store.applyAppState(await this.bridge.readState())
this.gate.hide()
await this.catalog.refresh()
await this.catalog.syncGames([])
} catch (error: unknown) {
this.log.appendLine(error instanceof Error ? error.message : String(error))
} finally {
this.store.applyProgress(null)
}
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* The DOM chores this window has, in one place.
*
* `requireElement` throws rather than returning null, and checks what it found
* against the element type asked for: every id it is called with is in index.html, so
* a missing or retyped one is a mistake in this repository and should say so loudly
* instead of silently rendering half a window.
*/
export function requireElement<TElement extends HTMLElement> (
id: string,
type: abstract new () => TElement
): TElement {
const found = document.getElementById(id)
if (found === null) throw new Error(`the element #${id} is missing from index.html`)
if (!(found instanceof type)) throw new Error(`#${id} is not a ${type.name}`)
return found
}
export function createElement<TTag extends keyof HTMLElementTagNameMap> (
tag: TTag,
className?: string,
text?: string
): HTMLElementTagNameMap[TTag] {
const node = document.createElement(tag)
if (className !== undefined) node.className = className
if (text !== undefined) node.textContent = text
return node
}
export function setText (node: HTMLElement, value: string | number | null): void {
node.textContent = value === null ? '' : String(value)
}
export function setHidden (node: HTMLElement, hidden: boolean): void {
node.hidden = hidden
}
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- Nothing is loaded from the network except box art, and no inline code
runs: the app ships its own script and stylesheet. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
<title>WarpEngine Store</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="bar">
<button id="nav-toggle" class="nav-toggle" aria-controls="side" aria-expanded="true">
<span aria-hidden="true"></span>
</button>
<div class="bar-title">
<span class="logo" aria-hidden="true"></span>
<span id="app-name">WarpEngine Store</span>
<span class="store-id" id="store-id"></span>
</div>
<div class="bar-actions">
<span class="progress" id="progress" hidden></span>
</div>
</header>
<div class="shell">
<!-- The side menu: which store, what to do with it, and what to look at.
Collapsed with the button in the bar; the choice is remembered. -->
<aside class="side" id="side">
<section class="side-block">
<h2 class="side-head" id="head-stores"></h2>
<div class="store-list" id="store-list"></div>
<button id="add-store" class="btn btn-ghost btn-wide"></button>
</section>
<section class="side-block">
<h2 class="side-head" id="head-actions"></h2>
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
<button id="refresh" class="btn btn-wide" disabled></button>
</section>
<section class="side-block side-cats">
<h2 class="side-head" id="head-cats"></h2>
<nav class="cats" id="cats"></nav>
</section>
<section class="side-block side-foot">
<label class="side-lang">
<span id="head-lang"></span>
<select id="locale" class="select" aria-label="Language"></select>
</label>
</section>
</aside>
<div class="content">
<!-- Shown instead of the grid when there is nothing to drive yet. -->
<section id="gate" class="gate" hidden>
<h1 id="gate-title"></h1>
<p id="gate-body"></p>
<div class="gate-actions">
<label id="gate-choice" class="gate-choice" hidden>
<span id="gate-choice-label"></span>
<select id="gate-select" class="select"></select>
</label>
<button id="gate-action" class="btn btn-primary" hidden></button>
<a id="gate-link" class="link" href="#" hidden></a>
</div>
</section>
<main id="grid" class="grid" hidden></main>
<section id="empty" class="empty" hidden></section>
<footer class="log">
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
<div class="log-lines" id="log-lines" hidden></div>
<div class="log-paths" id="log-paths"></div>
</footer>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import { RendererApplication } from './RendererApplication'
// The renderer's entry point. Errors here would otherwise be invisible: the main
// process log stays empty when the page throws, so the window says it out loud.
void new RendererApplication().start().catch((error: unknown): void => {
console.error(error instanceof Error ? error.message : String(error))
})
+133
View File
@@ -0,0 +1,133 @@
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
import type { GameDto } from '../../shared/contracts/dto/GameDto'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages'
import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle'
import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter'
/** How far a running sync has got, for the counter in the bar. */
export interface SyncProgress {
readonly total: number
readonly done: number
readonly label: string
}
/** Everything the window draws itself from. Read-only to everyone but the store. */
export interface AppState {
readonly locale: Locale
readonly locales: readonly Locale[]
readonly messages: MessageBundle
readonly navigationOpen: boolean
readonly pythonVersion: string | null
readonly stores: readonly InstalledStoreDto[]
readonly currentStore: InstalledStoreDto | null
readonly engine: EngineVersionDto | null
readonly minimumEngineVersion: string
readonly registryUrl: string
readonly defaultStoreRoot: string
readonly games: readonly GameDto[]
readonly paths: StorePathsDto | null
readonly filter: CategoryFilter
readonly busy: boolean
readonly progress: SyncProgress | null
}
const INITIAL_STATE: AppState = {
locale: 'en',
locales: ['en'],
messages: ENGLISH_MESSAGES,
navigationOpen: true,
pythonVersion: null,
stores: [],
currentStore: null,
engine: null,
minimumEngineVersion: '',
registryUrl: '',
defaultStoreRoot: '',
games: [],
paths: null,
filter: ALL_CATEGORIES,
busy: false,
progress: null
}
export type AppStateListener = (state: AppState) => void
/**
* The window's single source of truth.
*
* Every mutator is named after what it changes, and every one of them notifies: the
* views re-render from the state rather than being poked individually, so there is no
* way to update the model and forget the screen.
*/
export class AppStore {
private state: AppState = INITIAL_STATE
private readonly listeners: AppStateListener[] = []
public readState (): AppState {
return this.state
}
public subscribe (listener: AppStateListener): void {
this.listeners.push(listener)
}
public applyAppState (dto: AppStateDto): void {
this.state = {
...this.state,
locale: dto.locale,
locales: dto.locales,
messages: dto.messages,
navigationOpen: dto.navigationOpen,
pythonVersion: dto.pythonVersion,
stores: dto.stores,
currentStore: dto.currentStore,
engine: dto.engine,
minimumEngineVersion: dto.minimumEngineVersion,
registryUrl: dto.registryUrl,
defaultStoreRoot: dto.defaultStoreRoot
}
this.notify()
}
public applyMessages (locale: Locale, messages: MessageBundle): void {
this.state = { ...this.state, locale, messages }
this.notify()
}
public applyNavigationOpen (open: boolean): void {
this.state = { ...this.state, navigationOpen: open }
this.notify()
}
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
this.notify()
}
public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void {
this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES }
this.notify()
}
public applyFilter (filter: CategoryFilter): void {
this.state = { ...this.state, filter }
this.notify()
}
public applyBusy (busy: boolean): void {
this.state = { ...this.state, busy, progress: busy ? this.state.progress : null }
this.notify()
}
public applyProgress (progress: SyncProgress | null): void {
this.state = { ...this.state, progress }
this.notify()
}
private notify (): void {
for (const listener of this.listeners) listener(this.state)
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { GameDto } from '../../shared/contracts/dto/GameDto'
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
/** Which axis a category narrows the grid along. */
export type CategoryKind = 'group' | 'platform' | 'mode'
export interface CategoryFilter {
readonly kind: CategoryKind
readonly value: string
}
export interface CategoryItem extends CategoryFilter {
readonly label: string
readonly count: number
}
export interface CategorySection {
readonly title: string | null
readonly items: readonly CategoryItem[]
}
export const ALL_CATEGORIES: CategoryFilter = { kind: 'group', value: 'all' }
export function isSameFilter (left: CategoryFilter, right: CategoryFilter): boolean {
return left.kind === right.kind && left.value === right.value
}
export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
switch (filter.kind) {
case 'platform':
return game.platform === filter.value
case 'mode':
return game.mode === filter.value
case 'group':
return matchesGroup(game, filter.value)
}
}
function matchesGroup (game: GameDto, group: string): boolean {
switch (group) {
case 'installed':
return game.installed
case 'updates':
return game.updateAvailable
case 'available':
return !game.installed
default:
return true
}
}
/**
* The categories, built from what the catalog actually contains.
*
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
* title on this machine, the platform it was built with, and whether it runs here or
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
* a single value is left out too — a filter that changes nothing is noise.
*/
export function buildCategorySections (
games: readonly GameDto[],
messages: MessageBundle
): readonly CategorySection[] {
const count = (predicate: (game: GameDto) => boolean): number => games.filter(predicate).length
const sections: CategorySection[] = []
const groups: readonly CategoryItem[] = [
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) }
]
sections.push({
title: null,
items: groups.filter((item: CategoryItem): boolean => item.value === 'all' || item.count > 0)
})
const platforms = [...new Set(games.map((game: GameDto): string => game.platform))]
.filter((platform: string): boolean => platform.length > 0)
.sort((left: string, right: string): number => left.localeCompare(right))
if (platforms.length > 1) {
sections.push({
title: messages.catPlatform,
items: platforms.map((platform: string): CategoryItem => ({
kind: 'platform',
value: platform,
label: platform,
count: count((game: GameDto): boolean => game.platform === platform)
}))
})
}
const modes = [...new Set(games.map((game: GameDto): string => game.mode))]
if (modes.length > 1) {
sections.push({
title: messages.catMode,
items: modes.map((mode: string): CategoryItem => ({
kind: 'mode',
value: mode,
label: mode === 'web' ? messages.hosted : messages.native,
count: count((game: GameDto): boolean => game.mode === mode)
}))
})
}
return sections
}
/**
* A category can vanish under us — the last title of a platform is removed, or an
* update is applied — and a filter matching nothing would look like an empty
* catalog. Falling back to everything is the honest answer.
*/
export function resolveFilter (
filter: CategoryFilter,
sections: readonly CategorySection[]
): CategoryFilter {
const known = sections
.flatMap((section: CategorySection): readonly CategoryItem[] => section.items)
.some((item: CategoryItem): boolean => isSameFilter(item, filter))
return known ? filter : ALL_CATEGORIES
}
+320
View File
@@ -0,0 +1,320 @@
/* One dark theme, no assets: the box art is the only image the app loads. */
:root {
--bg: #11151c;
--panel: #182029;
--panel-2: #1e2732;
--line: #2a3440;
--ink: #e8eef5;
--ink-dim: #93a4b8;
--accent: #37b98a;
--accent-ink: #05130d;
--warn: #e0a44a;
--radius: 12px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* --- shell: the side menu and everything else --------------------------- */
.shell {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden; /* so the collapsed menu is clipped rather than scrolled to */
}
.content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.side {
--side-width: 244px;
width: var(--side-width);
flex: none;
background: var(--panel);
border-right: 1px solid var(--line);
/* The menu itself does not scroll: the category list does. Letting the whole
column scroll made the bottom block — pinned there with margin-top: auto —
sit on top of the overflowing categories. */
overflow: hidden;
padding: 14px 12px 16px;
display: flex;
flex-direction: column;
gap: 18px;
transition: margin-left .16s ease-out;
}
body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
.side-cats { flex: 1; min-height: 0; }
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
.side-head {
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ink-dim);
margin: 0 0 2px 4px;
}
.btn-wide { width: 100%; text-align: left; }
.nav-toggle {
font: inherit;
font-size: 15px;
line-height: 1;
color: var(--ink-dim);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
}
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
/* Store switcher: one row per store on this machine, the open one marked. */
.store-list { display: flex; flex-direction: column; gap: 4px; }
.store-row {
font: inherit;
text-align: left;
color: var(--ink);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 1px;
}
.store-row:hover:not(:disabled) { background: var(--panel-2); }
.store-row.is-active {
background: var(--panel-2);
border-color: #2f5a49;
}
.store-row .store-row-name { font-weight: 600; }
.store-row .store-row-id {
font-size: 11px;
color: var(--ink-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-row:disabled { opacity: .55; cursor: default; }
/* Categories: what the catalog is filtered down to. */
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
.cat {
font: inherit;
text-align: left;
color: var(--ink-dim);
background: transparent;
border: 0;
border-radius: 8px;
padding: 5px 10px;
cursor: pointer;
display: flex;
align-items: baseline;
gap: 8px;
}
.cat:hover { background: var(--panel-2); color: var(--ink); }
.cat.is-active { background: var(--panel-2); color: var(--ink); font-weight: 600; }
.cat-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cat-count { margin-left: auto; font-size: 11px; font-variant-numeric: tabular-nums; }
.cat-group {
font-size: 10.5px;
font-weight: 700;
letter-spacing: .07em;
text-transform: uppercase;
color: #6b7d92;
padding: 8px 10px 2px;
}
.side-lang { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-dim); }
.side-lang .select { margin-left: auto; }
/* --- top bar ------------------------------------------------------------ */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 18px;
background: var(--panel);
border-bottom: 1px solid var(--line);
flex: none;
}
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; margin-right: auto; }
.logo { color: var(--accent); font-size: 18px; }
.store-id {
font-weight: 500;
font-size: 12px;
color: var(--ink-dim);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 8px;
}
.bar-actions { display: flex; align-items: center; gap: 8px; }
.progress { color: var(--ink-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
/* --- controls ----------------------------------------------------------- */
.btn {
font: inherit;
font-weight: 600;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background .15s, border-color .15s, transform .05s;
}
.btn:hover:not(:disabled) { background: #26313e; border-color: #3a4757; }
.btn:active:not(:disabled) { transform: scale(.97); }
.btn:disabled { opacity: .45; cursor: default; }
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
.btn-ghost { background: transparent; color: var(--ink-dim); }
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
.select {
font: inherit;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 8px;
}
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
/* --- gate (no python, or no store yet) ---------------------------------- */
.gate {
margin: auto;
max-width: 520px;
padding: 28px;
text-align: center;
}
.gate h1 { font-size: 20px; margin: 0 0 10px; }
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
/* --- the grid ----------------------------------------------------------- */
.grid {
flex: 1;
overflow-y: auto;
display: grid;
/* Narrower than it was: the side menu takes 244px off the window, and at 260px
a 1040px window had room for only two columns. */
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 14px;
padding: 18px;
/* Content-sized rows, spelled out. Left to `auto` the implicit rows split the
grid's height evenly — every card came out 94px tall with its box art
collapsed to nothing and its buttons clipped away, which is how the grid
looked before this was measured. */
grid-auto-rows: max-content;
align-content: start;
}
.empty { margin: auto; color: var(--ink-dim); }
.gate { overflow-y: auto; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card.is-installed { border-color: #2f5a49; }
.art {
/* One height for every card, art or not, so the titles and the buttons line up
across a row. The images are cropped anyway (object-fit: cover). */
height: 148px;
flex: none;
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.art img { width: 100%; height: 100%; object-fit: cover; }
.art-glyph { font-size: 44px; font-weight: 700; color: #263341; }
.body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.body h2 { font-size: 15px; margin: 0; }
.meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.badge {
font-size: 11px;
font-weight: 600;
border-radius: 999px;
padding: 2px 8px;
border: 1px solid var(--line);
color: var(--ink-dim);
}
.badge-app { color: var(--accent); border-color: #2f5a49; }
.badge-web { color: var(--warn); border-color: #5a4a2f; }
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
.desc {
margin: 0;
font-size: 12.5px;
color: var(--ink-dim);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.actions { display: flex; gap: 8px; margin-top: auto; }
/* --- log ---------------------------------------------------------------- */
.log {
flex: none;
background: var(--panel);
border-top: 1px solid var(--line);
padding: 8px 18px 10px;
}
.log-toggle {
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--ink-dim);
background: none;
border: 0;
padding: 0 0 4px;
cursor: pointer;
}
.log-lines {
max-height: 150px;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
color: var(--ink-dim);
background: #0d1117;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 6px;
}
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-paths { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.paths-line {
font-size: 11.5px;
color: var(--ink-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
flex: 1;
min-width: 200px;
}
+28
View File
@@ -0,0 +1,28 @@
import type { GameDto } from '../../shared/contracts/dto/GameDto'
import { requireElement, setHidden, setText } from '../dom/Dom'
import type { AppState } from '../state/AppStore'
import { matchesFilter } from '../state/CategoryFilter'
import type { GameCardView } from './GameCardView'
/** The grid, and the sentence that stands in for it when there is nothing to show. */
export class CatalogGridView {
private readonly grid = requireElement('grid', HTMLElement)
private readonly empty = requireElement('empty', HTMLElement)
public constructor (private readonly cards: GameCardView) {}
public render (state: AppState): void {
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
this.grid.replaceChildren(...shown.map((game: GameDto): HTMLElement =>
this.cards.createCard(game, state.messages, state.busy)))
setHidden(this.grid, shown.length === 0)
this.grid.scrollTop = 0
setHidden(this.empty, shown.length !== 0)
setText(this.empty, state.games.length === 0 ? state.messages.noGames : state.messages.noMatch)
}
public hide (): void {
setHidden(this.grid, true)
setHidden(this.empty, true)
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { GameDto } from '../../shared/contracts/dto/GameDto'
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
import { createElement } from '../dom/Dom'
export interface GameCardViewCallbacks {
readonly onInstall: (name: string) => void
readonly onLaunch: (name: string) => void
readonly onRemove: (name: string) => void
}
/**
* One card.
*
* A card is a function of a title and the strings: it holds no state of its own, so
* the grid can throw the lot away and rebuild after every listing.
*/
export class GameCardView {
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const card = createElement('article', 'card')
if (game.installed) card.classList.add('is-installed')
card.appendChild(this.createArt(game))
card.appendChild(this.createBody(game, messages, busy))
return card
}
private createArt (game: GameDto): HTMLElement {
const art = createElement('div', 'art')
if (game.imageUrl !== null) {
const image = createElement('img')
image.src = game.imageUrl
image.alt = ''
image.loading = 'lazy'
art.appendChild(image)
return art
}
// No box art in the catalog: the first letter, on the same band an image would
// fill, so a row of cards stays aligned either way.
art.appendChild(createElement('span', 'art-glyph', game.title.slice(0, 1).toUpperCase()))
return art
}
private createBody (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const body = createElement('div', 'body')
body.appendChild(createElement('h2', undefined, game.title))
body.appendChild(this.createMeta(game, messages))
if (game.description.length > 0) {
body.appendChild(createElement('p', 'desc', game.description))
}
body.appendChild(this.createActions(game, messages, busy))
return body
}
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
const meta = createElement('div', 'meta')
const mode = createElement('span', `badge badge-${game.mode}`,
game.mode === 'web' ? messages.hosted : messages.native)
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
meta.appendChild(mode)
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
meta.appendChild(createElement('span', 'version',
game.installed && game.installedVersion !== null
? `${game.installedVersion} · ${messages.installed}`
: game.version))
return meta
}
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const actions = createElement('div', 'actions')
const primary = createElement('button', 'btn btn-primary')
primary.disabled = busy
if (game.installed && !game.updateAvailable) {
primary.textContent = game.mode === 'web' ? messages.open : messages.play
primary.disabled = busy || !game.launchable
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
} else {
primary.textContent = game.updateAvailable ? messages.update : messages.install
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
}
actions.appendChild(primary)
if (game.installed) {
const remove = createElement('button', 'btn btn-ghost', messages.remove)
remove.disabled = busy
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
actions.appendChild(remove)
}
return actions
}
}
+91
View File
@@ -0,0 +1,91 @@
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
/** A button on the gate, and what choosing it does. */
export interface GateAction {
readonly label: string
readonly perform: (chosen: RegistryStoreDto | null) => void
}
export interface GateLink {
readonly label: string
readonly url: string
}
export interface GatePresentation {
readonly title: string
readonly body: string
readonly action?: GateAction
readonly link?: GateLink
readonly choices?: readonly RegistryStoreDto[]
}
/**
* The screen shown instead of the grid when there is nothing to drive: no Python, no
* store yet, an engine too old, or a registry that cannot be reached.
*/
export class GateView {
private readonly section = requireElement('gate', HTMLElement)
private readonly title = requireElement('gate-title', HTMLElement)
private readonly body = requireElement('gate-body', HTMLElement)
private readonly choice = requireElement('gate-choice', HTMLElement)
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
private readonly select = requireElement('gate-select', HTMLSelectElement)
private readonly button = requireElement('gate-action', HTMLButtonElement)
private readonly link = requireElement('gate-link', HTMLAnchorElement)
public constructor (private readonly onOpenUrl: (url: string) => void) {}
public show (presentation: GatePresentation, messages: MessageBundle): void {
setHidden(this.section, false)
setText(this.title, presentation.title)
setText(this.body, presentation.body)
this.renderChoices(presentation.choices ?? [], messages)
this.renderAction(presentation)
this.renderLink(presentation.link ?? null)
}
public hide (): void {
setHidden(this.section, true)
}
public get visible (): boolean {
return !this.section.hidden
}
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
setHidden(this.choice, choices.length < 2)
if (choices.length < 2) return
setText(this.choiceLabel, messages.setupChoose)
this.select.replaceChildren(...choices.map((store: RegistryStoreDto, index: number): HTMLOptionElement => {
const option = createElement('option', undefined, store.name)
option.value = String(index)
return option
}))
}
private renderAction (presentation: GatePresentation): void {
const action = presentation.action
setHidden(this.button, action === undefined)
this.button.disabled = false
if (action === undefined) return
setText(this.button, action.label)
this.button.onclick = (): void => {
const choices = presentation.choices ?? []
const index = Number(this.select.value)
action.perform(choices[Number.isFinite(index) ? index : 0] ?? choices[0] ?? null)
}
}
private renderLink (link: GateLink | null): void {
setHidden(this.link, link === null)
if (link === null) return
setText(this.link, link.label)
this.link.onclick = (event: MouseEvent): void => {
event.preventDefault()
this.onOpenUrl(link.url)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import { createElement, requireElement, setText } from '../dom/Dom'
import type { AppState } from '../state/AppStore'
const MAX_LOG_LINES = 400
export interface LogDrawerViewCallbacks {
readonly onOpenFolder: (directory: string) => void
}
/** The store's own output, verbatim, and the folders everything lands in. */
export class LogDrawerView {
private readonly toggle = requireElement('log-toggle', HTMLButtonElement)
private readonly lines = requireElement('log-lines', HTMLElement)
private readonly pathsBox = requireElement('log-paths', HTMLElement)
public constructor (private readonly callbacks: LogDrawerViewCallbacks) {
this.toggle.addEventListener('click', (): void => {
this.lines.hidden = !this.lines.hidden
this.toggle.setAttribute('aria-expanded', String(!this.lines.hidden))
})
}
public render (state: AppState): void {
setText(this.toggle, state.messages.log)
this.pathsBox.replaceChildren()
const paths = state.paths
if (paths === null) return
this.pathsBox.appendChild(createElement('div', 'paths-line',
`${state.messages.paths}: ${paths.storeFolder} · ${paths.menuGroup}`))
const folders: readonly (readonly [string, string])[] = [
[state.messages.openStoreFolder, paths.storeFolder],
[state.messages.openMenuFolder, paths.menuGroup]
]
for (const [label, directory] of folders) {
const button = createElement('button', 'btn btn-tiny', label)
button.addEventListener('click', (): void => { this.callbacks.onOpenFolder(directory) })
this.pathsBox.appendChild(button)
}
}
public appendLine (line: string): void {
this.lines.appendChild(createElement('div', 'log-line', line))
while (this.lines.childElementCount > MAX_LOG_LINES) {
const first = this.lines.firstElementChild
if (first === null) break
first.remove()
}
this.lines.scrollTop = this.lines.scrollHeight
}
}
+124
View File
@@ -0,0 +1,124 @@
import { createElement, requireElement, setText } from '../dom/Dom'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
import type { Locale } from '../../shared/i18n/MessageBundle'
import {
buildCategorySections, isSameFilter, type CategoryFilter, type CategoryItem
} from '../state/CategoryFilter'
import type { AppState } from '../state/AppStore'
export interface SideMenuViewCallbacks {
readonly onSelectStore: (home: string) => void
readonly onAddStore: () => void
readonly onSyncAll: () => void
readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void
readonly onSelectLocale: (locale: string) => void
}
/**
* The side menu: which store, what to do with it, and what to look at.
*
* Everything that is not a title lives here, and the bar's button folds it away.
*/
export class SideMenuView {
private readonly storesHead = requireElement('head-stores', HTMLElement)
private readonly actionsHead = requireElement('head-actions', HTMLElement)
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
private readonly languageHead = requireElement('head-lang', HTMLElement)
private readonly storeList = requireElement('store-list', HTMLElement)
private readonly addStore = requireElement('add-store', HTMLButtonElement)
private readonly syncAll = requireElement('sync-all', HTMLButtonElement)
private readonly refresh = requireElement('refresh', HTMLButtonElement)
private readonly categories = requireElement('cats', HTMLElement)
private readonly locale = requireElement('locale', HTMLSelectElement)
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
this.addStore.addEventListener('click', callbacks.onAddStore)
this.syncAll.addEventListener('click', callbacks.onSyncAll)
this.refresh.addEventListener('click', callbacks.onRefresh)
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
}
public render (state: AppState): void {
setText(this.storesHead, state.messages.stores)
setText(this.actionsHead, state.messages.actions)
setText(this.categoriesHead, state.messages.categories)
setText(this.languageHead, state.messages.language)
setText(this.addStore, state.messages.addStore)
setText(this.syncAll, state.messages.syncAll)
setText(this.refresh, state.messages.refresh)
this.renderStores(state)
this.renderCategories(state)
this.renderLocales(state)
this.renderEnabled(state)
}
private renderStores (state: AppState): void {
const activeHome = state.currentStore === null ? null : state.currentStore.home
// Two stores can carry the same id in different roots — the same catalog
// installed twice. Then the id says nothing and the folder is what tells them
// apart, so that is what the row shows.
const ambiguousIds = new Set(state.stores
.filter((store: InstalledStoreDto, index: number): boolean =>
state.stores.findIndex((other: InstalledStoreDto): boolean => other.id === store.id) !== index)
.map((store: InstalledStoreDto): string => store.id))
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
const row = createElement('button', 'store-row')
if (store.home === activeHome) row.classList.add('is-active')
row.appendChild(createElement('span', 'store-row-name', store.name))
row.appendChild(createElement('span', 'store-row-id',
ambiguousIds.has(store.id) ? store.home : store.id))
row.title = store.home
row.addEventListener('click', (): void => {
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
})
return row
}))
}
private renderCategories (state: AppState): void {
const sections = buildCategorySections(state.games, state.messages)
const nodes: HTMLElement[] = []
for (const section of sections) {
if (section.title !== null) nodes.push(createElement('div', 'cat-group', section.title))
for (const item of section.items) nodes.push(this.createCategoryRow(item, state.filter))
}
this.categories.replaceChildren(...nodes)
}
private createCategoryRow (item: CategoryItem, active: CategoryFilter): HTMLElement {
const row = createElement('button', 'cat')
if (isSameFilter(item, active)) row.classList.add('is-active')
row.appendChild(createElement('span', 'cat-label', item.label))
row.appendChild(createElement('span', 'cat-count', String(item.count)))
row.addEventListener('click', (): void => {
this.callbacks.onSelectCategory({ kind: item.kind, value: item.value })
})
return row
}
private renderLocales (state: AppState): void {
const current = this.locale.value
if (current === state.locale && this.locale.options.length === state.locales.length) return
this.locale.replaceChildren(...state.locales.map((locale: Locale): HTMLOptionElement => {
const option = createElement('option', undefined, locale.toUpperCase())
option.value = locale
option.selected = locale === state.locale
return option
}))
}
/**
* While the store is working only what would start a second call is disabled; the
* filters stay live because they change what is on screen and nothing on disk.
*/
private renderEnabled (state: AppState): void {
const hasStore = state.currentStore !== null
this.syncAll.disabled = state.busy || !hasStore
this.refresh.disabled = state.busy || !hasStore
this.addStore.disabled = state.busy
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
}
}
+30
View File
@@ -0,0 +1,30 @@
import { requireElement, setHidden, setText } from '../dom/Dom'
import type { AppState } from '../state/AppStore'
export interface TopBarViewCallbacks {
readonly onToggleNavigation: () => void
}
/** The bar: what this is, which store is open, and how far a sync has got. */
export class TopBarView {
private readonly appName = requireElement('app-name', HTMLElement)
private readonly storeId = requireElement('store-id', HTMLElement)
private readonly progress = requireElement('progress', HTMLElement)
private readonly navToggle = requireElement('nav-toggle', HTMLButtonElement)
public constructor (callbacks: TopBarViewCallbacks) {
this.navToggle.addEventListener('click', callbacks.onToggleNavigation)
}
public render (state: AppState): void {
setText(this.appName, state.messages.appName)
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
this.navToggle.title = state.messages.menu
this.navToggle.setAttribute('aria-label', state.messages.menu)
this.navToggle.setAttribute('aria-expanded', String(state.navigationOpen))
const progress = state.progress
setHidden(this.progress, progress === null)
setText(this.progress, progress === null ? '' : progress.label)
}
}