Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35a72336a |
@@ -272,9 +272,14 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
|
|||||||
second package while the first had just gone up with the same token, and the same
|
second package while the first had just gone up with the same token, and the same
|
||||||
command succeeded immediately afterwards.
|
command succeeded immediately afterwards.
|
||||||
|
|
||||||
Package names contain a space — `WarpEngine Client-1.5.0-arm64.dmg` — so the list of
|
Package names have no spaces in them — `WarpEngineClient-2.3.0-arm64.dmg` — because a
|
||||||
files is passed one path per line rather than as one string; splitting it on
|
space in a release asset is a space in every `curl`, script and shell command that ever
|
||||||
whitespace is what broke the first attempt at publishing 1.1.0.
|
touches it. The app itself is still called **WarpEngine Client**: that name is what
|
||||||
|
appears in the Dock and in `/Applications`, and only the file names were the problem.
|
||||||
|
|
||||||
|
The list of files is still passed one path per line rather than as one string, since a
|
||||||
|
path given on the command line can contain a space even when a built one cannot;
|
||||||
|
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
|
||||||
|
|
||||||
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
||||||
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
||||||
|
|||||||
+41
-40
@@ -1,41 +1,39 @@
|
|||||||
# WarpEngine Client 2.2.0
|
# WarpEngine Client 2.3.0
|
||||||
|
|
||||||
**A registry record is a name and a catalog.** Nothing else. The `config` field added in
|
**A card says which version you have, and offers the newer one.** Where the catalog has
|
||||||
2.1.0 is gone, and so is `storeRepositoryUrl` — along with the two store repositories they
|
moved on, an installed title's version line reads `0.1 → 0.3` instead of just the number
|
||||||
pointed at, which have been deleted.
|
on your disk, so the card answers both questions a person came to it with: what is
|
||||||
|
installed, and is there anything better.
|
||||||
|
|
||||||
2.1.0 had the registry say how each store behaves. That was the wrong shape. How a store
|
Which version is installed was already recorded — that is what `state.json` has always
|
||||||
behaves is fixed per installed client: this application carries its own store engine and
|
been for. What was missing was somewhere to act on it.
|
||||||
is the only thing that can see the machine it is running on. A copy of that on a server
|
|
||||||
was a second authority over decisions this side had already made correctly — including
|
|
||||||
which directories the store is allowed to delete from — and a second authority is a way
|
|
||||||
for the two to disagree.
|
|
||||||
|
|
||||||
Keeping two stores on one machine out of each other's files needs none of it. It is a
|
**The actions moved into a three-dot menu.** Installed cards now lead with **Play** (or
|
||||||
subfolder, and the client derives it:
|
**Open** for a hosted title) and put the rest behind the ⋮ button beside it:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **store id** | a slug of the catalog host — `teletypegames.org` becomes `teletypegames` |
|
| **Upgrade** | fetches whatever the catalog now has, replacing the old payload and menu entry. Greyed out when there is nothing newer |
|
||||||
| **store home** | `<id>-desktop`, under the usual store root |
|
| **Uninstall** | as before — the payload, the icon and the menu entry, and nothing else |
|
||||||
| **games folder** | `<id>`, inside the OS's usual place for programs |
|
|
||||||
| **delete boundary** | that same folder, and nothing outside it |
|
|
||||||
|
|
||||||
Derived from the *catalog* on purpose: the catalog is what a store is, so two records
|
Upgrade stays visible while disabled rather than appearing and disappearing: a menu whose
|
||||||
naming the same one are the same store and land in the same place. Installing the same
|
items come and go makes a person hunt for the one they used last time, and greyed out
|
||||||
store twice is idempotent rather than a way to orphan what is already there.
|
already says "not now".
|
||||||
|
|
||||||
**Existing installations are untouched.** A store already on the machine is recognised by
|
Playing stays the headline action even when an upgrade is waiting. The version on the disk
|
||||||
the `config.json` in its own home and keeps the identity written there — a store installed
|
still runs, and wanting to play it is not the same as wanting to wait for a download.
|
||||||
as `ttg` stays `ttg`, in `ttg-desktop`, with its games where they are. Only a *new* install
|
|
||||||
derives its id from the catalog.
|
**Package names have no spaces.** `WarpEngineClient-2.3.0-arm64.dmg` rather than
|
||||||
|
`WarpEngine Client-2.3.0-arm64.dmg`, and the same for the AppImage and both Windows
|
||||||
|
builds — a space in a release asset is a space in every `curl`, script and shell command
|
||||||
|
that ever touches it. The app is still called **WarpEngine Client**, which is what appears
|
||||||
|
in the Dock and in `/Applications`; only the file names changed.
|
||||||
|
|
||||||
### Also
|
### Also
|
||||||
|
|
||||||
The public `/stores` page loses its desktop card, which advertised a `curl … | sh` for a
|
The catalog refresh control's tooltip now says *Refresh the catalog*, because in Hungarian
|
||||||
repository that no longer exists; an ordinary computer is served by the app. The `/desktop`
|
it and the new Upgrade both wanted the word "Frissítés" and only one of them reloads a
|
||||||
URL now lands on the app rather than on a device tab, so what someone typing it wants is
|
list.
|
||||||
what they get.
|
|
||||||
|
|
||||||
### Opening it on macOS
|
### Opening it on macOS
|
||||||
|
|
||||||
@@ -54,18 +52,21 @@ Windows (installer, portable) packages the pipeline builds when the tag is pushe
|
|||||||
|
|
||||||
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test.
|
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test.
|
||||||
|
|
||||||
The install path was measured end to end against a local registry serving exactly the new
|
The upgrade path was tested on a sandbox store rather than reasoned about. Two titles were
|
||||||
record shape. The slug came out `teletypegames`, the home `teletypegames-desktop`, the
|
installed, then one of their state records was rewritten to claim an older build — which is
|
||||||
games subfolder `teletypegames`, and installing the same record twice landed in the same
|
exactly what the engine compares — and the window was asked what it would offer:
|
||||||
home. A record carrying `config` and `storeRepositoryUrl` — what a stale registry or a
|
|
||||||
tampering renderer might still send — changed nothing on disk, because neither field exists
|
|
||||||
in the model any more.
|
|
||||||
|
|
||||||
On this machine the check also shows both halves of the compatibility claim at once: the
|
```
|
||||||
registry's new slug reads `teletypegames`, while the store that was installed before today
|
BombExpert [newer] Upgrade:on Uninstall:on
|
||||||
is still found as `ttg` in `ttg-desktop`, listing its ten installed titles from
|
Rabbit Roller [current] Upgrade:off Uninstall:on
|
||||||
`Application Support/teletypegames`.
|
```
|
||||||
|
|
||||||
The site side was migrated and its specs re-run, and the frontend was built — which first
|
Pressing Upgrade runs the same call the window makes, and the record went from `0.1` to
|
||||||
required removing a dead `engines` list that had been failing `vue-tsc` on master, so that
|
`0.2` with the old payload removed first. The self-test now asserts that pairing on every
|
||||||
page could not be built to check at all.
|
installed card, because a closed menu photographs identically whether or not its items are
|
||||||
|
right.
|
||||||
|
|
||||||
|
The new package names were read off a real build (`WarpEngineClient-2.3.0-arm64.dmg`,
|
||||||
|
`…-arm64-mac.zip`) and checked against the release script's own file filter, which finds
|
||||||
|
both. The Windows names are `-Setup-` and `-Portable-` so the two `.exe` targets cannot
|
||||||
|
resolve to one name and overwrite each other.
|
||||||
|
|||||||
+15
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-client",
|
"name": "warp-engine-client",
|
||||||
"productName": "WarpEngine Client",
|
"productName": "WarpEngine Client",
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Teletype Games <games@teletype.hu>",
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
@@ -44,7 +44,11 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"dmg",
|
"dmg",
|
||||||
"zip"
|
"zip"
|
||||||
]
|
],
|
||||||
|
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
|
||||||
|
},
|
||||||
|
"dmg": {
|
||||||
|
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -52,6 +56,12 @@
|
|||||||
"portable"
|
"portable"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"nsis": {
|
||||||
|
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
|
"portable": {
|
||||||
|
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"category": "Game",
|
"category": "Game",
|
||||||
"target": [
|
"target": [
|
||||||
@@ -59,6 +69,9 @@
|
|||||||
"deb"
|
"deb"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"appImage": {
|
||||||
|
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
"afterPack": "scripts/after-pack.js"
|
"afterPack": "scripts/after-pack.js"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ api() {
|
|||||||
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list
|
# The list lives one path per line in a file and is read with `while IFS= read -r`. The
|
||||||
# lives one path per line in a file and is read with `while IFS= read -r`. A single
|
# built package names have no spaces in them any more, but a path given on the command
|
||||||
# variable looped over with $list splits on the space and uploads nothing.
|
# line still can — and a single variable looped over with $list splits on the space and
|
||||||
|
# uploads nothing, which is a silent way to publish a release with no assets.
|
||||||
LIST="$(mktemp)"
|
LIST="$(mktemp)"
|
||||||
trap 'rm -f "$LIST"' EXIT
|
trap 'rm -f "$LIST"' EXIT
|
||||||
if [ "$#" -gt 0 ]; then
|
if [ "$#" -gt 0 ]; then
|
||||||
|
|||||||
+4
-3
@@ -43,9 +43,10 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
|
|||||||
#
|
#
|
||||||
# - the version filter, because dist/ keeps whatever earlier builds left there
|
# - the version filter, because dist/ keeps whatever earlier builds left there
|
||||||
# and a release would quietly get the previous version's files attached;
|
# and a release would quietly get the previous version's files attached;
|
||||||
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one
|
# - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
|
||||||
# path per line in a file and is read with `while IFS= read -r`. Holding it in
|
# — but a path given as an argument still can, so the list stays one path per line in
|
||||||
# a single variable and looping over $list splits it on the space.
|
# a file, read with `while IFS= read -r`. Holding it in a single variable and looping
|
||||||
|
# over $list splits it on the space, and publishes nothing.
|
||||||
LIST="$(mktemp)"
|
LIST="$(mktemp)"
|
||||||
trap 'rm -f "$LIST"' EXIT
|
trap 'rm -f "$LIST"' EXIT
|
||||||
if [ "$#" -gt 0 ]; then
|
if [ "$#" -gt 0 ]; then
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface SelfTestReport {
|
|||||||
readonly locales: readonly string[]
|
readonly locales: readonly string[]
|
||||||
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
|
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
|
||||||
readonly iconControls: readonly string[]
|
readonly iconControls: readonly string[]
|
||||||
|
/** `<title> [current|newer] Upgrade:on|off Uninstall:on|off` per installed card. */
|
||||||
|
readonly cardMenus: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What changed after clicking a store that was not open. */
|
/** What changed after clicking a store that was not open. */
|
||||||
@@ -74,7 +76,11 @@ export class SelfTestRunner {
|
|||||||
// picker are the only way to reach those two actions now that neither has a label.
|
// picker are the only way to reach those two actions now that neither has a label.
|
||||||
const iconsNamed = report.iconControls.length === 2 &&
|
const iconsNamed = report.iconControls.length === 2 &&
|
||||||
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
|
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
|
||||||
const rendered = report.locales.length > 1 && iconsNamed && (
|
// Every installed card offers both actions, and Upgrade is enabled exactly when the
|
||||||
|
// version line says there is something newer. Uninstall is always available.
|
||||||
|
const menusAgree = report.cardMenus.every((entry: string): boolean =>
|
||||||
|
/\[newer\] \S+:on \S+:on$/.test(entry) || /\[current\] \S+:off \S+:on$/.test(entry))
|
||||||
|
const rendered = report.locales.length > 1 && iconsNamed && menusAgree && (
|
||||||
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
||||||
report.categories.length > 0 && report.activeCategory !== null) ||
|
report.categories.length > 0 && report.activeCategory !== null) ||
|
||||||
(report.gateVisible && report.gateAction.length > 0))
|
(report.gateVisible && report.gateAction.length > 0))
|
||||||
@@ -107,6 +113,16 @@ export class SelfTestRunner {
|
|||||||
locales: [...document.getElementById('locale').options].map((option) => option.value),
|
locales: [...document.getElementById('locale').options].map((option) => option.value),
|
||||||
// The two icon-only controls: a glyph with no accessible name is a button nobody
|
// The two icon-only controls: a glyph with no accessible name is a button nobody
|
||||||
// can identify, and the failure is silent because the icon still draws.
|
// can identify, and the failure is silent because the icon still draws.
|
||||||
|
// One entry per installed card: its title, whether the version line shows an
|
||||||
|
// upgrade, and the menu's two items with their disabled state. This is the only
|
||||||
|
// way to see that Upgrade is offered exactly when there is something newer —
|
||||||
|
// a screenshot shows a closed menu.
|
||||||
|
cardMenus: [...document.querySelectorAll('.card.is-installed')].map((card) => {
|
||||||
|
const items = [...card.querySelectorAll('.menu-item')]
|
||||||
|
.map((item) => item.textContent + (item.disabled ? ':off' : ':on'))
|
||||||
|
const arrow = card.querySelector('.version.has-update') === null ? 'current' : 'newer'
|
||||||
|
return (card.querySelector('h2') || {}).textContent + ' [' + arrow + '] ' + items.join(' ')
|
||||||
|
}),
|
||||||
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
|
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
|
||||||
.map((control) => {
|
.map((control) => {
|
||||||
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
|
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
|
||||||
@@ -132,7 +148,8 @@ export class SelfTestRunner {
|
|||||||
paths: readString(record, 'paths'),
|
paths: readString(record, 'paths'),
|
||||||
logLines: readNumber(record, 'logLines'),
|
logLines: readNumber(record, 'logLines'),
|
||||||
locales: readStringArray(record, 'locales'),
|
locales: readStringArray(record, 'locales'),
|
||||||
iconControls: readStringArray(record, 'iconControls')
|
iconControls: readStringArray(record, 'iconControls'),
|
||||||
|
cardMenus: readStringArray(record, 'cardMenus')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export class RendererApplication {
|
|||||||
|
|
||||||
this.grid = new CatalogGridView(new GameCardView({
|
this.grid = new CatalogGridView(new GameCardView({
|
||||||
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||||
|
// The same call as an install: a sync of one name fetches whatever the catalog
|
||||||
|
// now has for it, and the engine replaces the old payload and menu entry.
|
||||||
|
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||||
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
||||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -357,6 +357,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
}
|
}
|
||||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||||
|
|
||||||
|
/* --- the card's actions menu -------------------------------------------- */
|
||||||
|
/*
|
||||||
|
* A <details> holding the two actions that are not the card's headline. The card is the
|
||||||
|
* positioning context, and the panel is pinned to the button's right edge so it opens
|
||||||
|
* inward rather than off the side of the grid.
|
||||||
|
*/
|
||||||
|
.menu { position: relative; margin-left: auto; }
|
||||||
|
.menu-toggle { list-style: none; }
|
||||||
|
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
|
||||||
|
.menu-toggle::-webkit-details-marker { display: none; }
|
||||||
|
.menu-toggle::marker { content: ''; }
|
||||||
|
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
|
||||||
|
|
||||||
|
.menu-items {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 148px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-item:hover:not(:disabled) { background: #2b3746; }
|
||||||
|
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
.icon-dots { fill: currentColor; stroke: none; }
|
||||||
|
|
||||||
|
/* An installed version with a newer one behind it: the arrow carries the news, so the
|
||||||
|
colour only has to make it findable in a grid. */
|
||||||
|
.version.has-update { color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
|
||||||
/* --- log ----------------------------------------------------------------
|
/* --- log ----------------------------------------------------------------
|
||||||
No permanent footer: the panel is in the flow only while it is open, and the
|
No permanent footer: the panel is in the flow only while it is open, and the
|
||||||
switch for it sits in the side menu with everything else that is not a title. */
|
switch for it sits in the side menu with everything else that is not a title. */
|
||||||
|
|||||||
@@ -9,7 +9,26 @@ export class CatalogGridView {
|
|||||||
private readonly grid = requireElement('grid', HTMLElement)
|
private readonly grid = requireElement('grid', HTMLElement)
|
||||||
private readonly empty = requireElement('empty', HTMLElement)
|
private readonly empty = requireElement('empty', HTMLElement)
|
||||||
|
|
||||||
public constructor (private readonly cards: GameCardView) {}
|
public constructor (private readonly cards: GameCardView) {
|
||||||
|
this.closeMenusOnOutsideClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One listener for every card's actions menu.
|
||||||
|
*
|
||||||
|
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
|
||||||
|
* this: cards are rebuilt on every render, so a listener per card would be a listener
|
||||||
|
* per render. The grid is created once, which makes it the right place for it.
|
||||||
|
*/
|
||||||
|
private closeMenusOnOutsideClick (): void {
|
||||||
|
document.addEventListener('click', (event: MouseEvent): void => {
|
||||||
|
const target = event.target
|
||||||
|
const clicked = target instanceof Node ? target : null
|
||||||
|
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
|
||||||
|
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
public render (state: AppState): void {
|
public render (state: AppState): void {
|
||||||
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createElement } from '../dom/Dom'
|
|||||||
|
|
||||||
export interface GameCardViewCallbacks {
|
export interface GameCardViewCallbacks {
|
||||||
readonly onInstall: (name: string) => void
|
readonly onInstall: (name: string) => void
|
||||||
|
readonly onUpgrade: (name: string) => void
|
||||||
readonly onLaunch: (name: string) => void
|
readonly onLaunch: (name: string) => void
|
||||||
readonly onRemove: (name: string) => void
|
readonly onRemove: (name: string) => void
|
||||||
}
|
}
|
||||||
@@ -11,8 +12,11 @@ export interface GameCardViewCallbacks {
|
|||||||
/**
|
/**
|
||||||
* One card.
|
* One card.
|
||||||
*
|
*
|
||||||
* A card is a function of a title and the strings: it holds no state of its own, so
|
* A card is a function of a title and the strings: it holds no state of its own, so the
|
||||||
* the grid can throw the lot away and rebuild after every listing.
|
* grid can throw the lot away and rebuild after every listing. The one exception is the
|
||||||
|
* actions menu, whose open/closed state lives in a `<details>` element — and being
|
||||||
|
* thrown away is exactly what should happen to an open menu when the catalog changes
|
||||||
|
* under it.
|
||||||
*/
|
*/
|
||||||
export class GameCardView {
|
export class GameCardView {
|
||||||
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||||
@@ -71,13 +75,30 @@ export class GameCardView {
|
|||||||
meta.appendChild(badge)
|
meta.appendChild(badge)
|
||||||
}
|
}
|
||||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||||
meta.appendChild(createElement('span', 'version',
|
meta.appendChild(this.createVersion(game, messages))
|
||||||
game.installed && game.installedVersion !== null
|
|
||||||
? `${game.installedVersion} · ${messages.installed}`
|
|
||||||
: game.version))
|
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version, and what the catalog has that this machine does not.
|
||||||
|
*
|
||||||
|
* Both numbers, when they differ: which version is installed is the thing a person
|
||||||
|
* came to the card to find out, and "there is a newer one" is only meaningful next to
|
||||||
|
* it. Where they agree, the second number would be noise.
|
||||||
|
*/
|
||||||
|
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||||
|
if (!game.installed || game.installedVersion === null) {
|
||||||
|
return createElement('span', 'version', game.version)
|
||||||
|
}
|
||||||
|
if (!game.updateAvailable) {
|
||||||
|
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
|
||||||
|
}
|
||||||
|
const version = createElement('span', 'version has-update',
|
||||||
|
`${game.installedVersion} → ${game.version}`)
|
||||||
|
version.title = messages.updateAvailable
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
const actions = createElement('div', 'actions')
|
const actions = createElement('div', 'actions')
|
||||||
|
|
||||||
@@ -89,25 +110,88 @@ export class GameCardView {
|
|||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = createElement('button', 'btn btn-primary')
|
actions.appendChild(this.createPrimary(game, messages, busy))
|
||||||
primary.disabled = busy
|
// Only an installed title has anything in the menu: nothing to upgrade and nothing
|
||||||
|
// to uninstall until there is something on the disk.
|
||||||
|
if (game.installed) actions.appendChild(this.createMenu(game, messages, busy))
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
if (game.installed && !game.updateAvailable) {
|
/**
|
||||||
|
* The one action a card leads with.
|
||||||
|
*
|
||||||
|
* For an installed title that is playing it — including when an upgrade is waiting,
|
||||||
|
* because the version on the disk still runs and wanting to play it is not the same
|
||||||
|
* as wanting to wait for a download.
|
||||||
|
*/
|
||||||
|
private createPrimary (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const primary = createElement('button', 'btn btn-primary')
|
||||||
|
if (game.installed) {
|
||||||
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
||||||
primary.disabled = busy || !game.launchable
|
primary.disabled = busy || !game.launchable
|
||||||
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||||
} else {
|
return primary
|
||||||
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
|
||||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
|
||||||
}
|
}
|
||||||
actions.appendChild(primary)
|
primary.textContent = messages.install
|
||||||
|
primary.disabled = busy
|
||||||
|
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
|
||||||
if (game.installed) {
|
/**
|
||||||
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
* The three-dot menu: upgrade, and uninstall.
|
||||||
remove.disabled = busy
|
*
|
||||||
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
* A `<details>` rather than a scripted popover, so the open state is the DOM's and the
|
||||||
actions.appendChild(remove)
|
* keyboard works without being taught to. Upgrade is present but disabled when there
|
||||||
|
* is nothing newer — a menu whose items appear and disappear makes a person hunt for
|
||||||
|
* the one they used last time, and "greyed out" already says "not now".
|
||||||
|
*/
|
||||||
|
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const menu = createElement('details', 'menu')
|
||||||
|
const toggle = createElement('summary', 'icon-btn menu-toggle')
|
||||||
|
toggle.title = messages.moreActions
|
||||||
|
toggle.setAttribute('aria-label', messages.moreActions)
|
||||||
|
toggle.appendChild(this.createDotsIcon())
|
||||||
|
menu.appendChild(toggle)
|
||||||
|
|
||||||
|
const items = createElement('div', 'menu-items')
|
||||||
|
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
|
||||||
|
(): void => { this.callbacks.onUpgrade(game.name) }))
|
||||||
|
items.appendChild(this.createMenuItem(messages.uninstall, busy,
|
||||||
|
(): void => { this.callbacks.onRemove(game.name) }))
|
||||||
|
menu.appendChild(items)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMenuItem (
|
||||||
|
label: string,
|
||||||
|
disabled: boolean,
|
||||||
|
perform: () => void
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const item = createElement('button', 'menu-item', label)
|
||||||
|
item.disabled = disabled
|
||||||
|
item.addEventListener('click', (): void => {
|
||||||
|
// Close before acting: the click starts work that re-renders the grid, and a menu
|
||||||
|
// left open would vanish mid-gesture rather than answer the press.
|
||||||
|
item.closest('details')?.removeAttribute('open')
|
||||||
|
perform()
|
||||||
|
})
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDotsIcon (): SVGSVGElement {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
svg.setAttribute('class', 'icon icon-dots')
|
||||||
|
svg.setAttribute('viewBox', '0 0 16 16')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
svg.setAttribute('focusable', 'false')
|
||||||
|
for (const y of [3.5, 8, 12.5]) {
|
||||||
|
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||||||
|
dot.setAttribute('cx', '8')
|
||||||
|
dot.setAttribute('cy', String(y))
|
||||||
|
dot.setAttribute('r', '1.35')
|
||||||
|
svg.appendChild(dot)
|
||||||
}
|
}
|
||||||
return actions
|
return svg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
export const ENGLISH_MESSAGES = {
|
export const ENGLISH_MESSAGES = {
|
||||||
appName: 'WarpEngine Client',
|
appName: 'WarpEngine Client',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh the catalog',
|
||||||
install: 'Install',
|
install: 'Install',
|
||||||
update: 'Update',
|
upgrade: 'Upgrade',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
remove: 'Remove',
|
uninstall: 'Uninstall',
|
||||||
|
moreActions: 'More actions',
|
||||||
installed: 'installed',
|
installed: 'installed',
|
||||||
native: 'native',
|
native: 'native',
|
||||||
hosted: 'hosted',
|
hosted: 'hosted',
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
|
|||||||
*/
|
*/
|
||||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||||
appName: 'WarpEngine Client',
|
appName: 'WarpEngine Client',
|
||||||
refresh: 'Frissítés',
|
refresh: 'Katalógus frissítése',
|
||||||
install: 'Telepítés',
|
install: 'Telepítés',
|
||||||
update: 'Frissítés',
|
upgrade: 'Frissítés',
|
||||||
play: 'Indítás',
|
play: 'Indítás',
|
||||||
open: 'Megnyitás',
|
open: 'Megnyitás',
|
||||||
remove: 'Eltávolítás',
|
uninstall: 'Eltávolítás',
|
||||||
|
moreActions: 'További műveletek',
|
||||||
installed: 'telepítve',
|
installed: 'telepítve',
|
||||||
native: 'natív',
|
native: 'natív',
|
||||||
hosted: 'hosztolt',
|
hosted: 'hosztolt',
|
||||||
|
|||||||
Reference in New Issue
Block a user