updtae
This commit is contained in:
@@ -62,6 +62,10 @@
|
||||
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
|
||||
case "syncing":
|
||||
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
|
||||
case "pulling_tiles":
|
||||
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
|
||||
case "removing":
|
||||
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null };
|
||||
case "update":
|
||||
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
|
||||
case "launch":
|
||||
@@ -76,6 +80,29 @@
|
||||
}
|
||||
});
|
||||
|
||||
// What the DHP has baked, for the sets this PC holds, since it last looked. Zero means
|
||||
// level with the site - which is worth saying nothing at all about.
|
||||
// Chosen, and not all here. ODIAN runs without it, so this is said beside Launch rather
|
||||
// than in place of it - but it IS said: the marketplace was the only place that knew.
|
||||
const unfinished = $derived(s?.unfinished ?? null);
|
||||
const unfinishedNames = $derived.by(() => {
|
||||
const titles = unfinished?.titles ?? [];
|
||||
if (titles.length === 0) return "";
|
||||
const shown = titles.slice(0, 2).join(", ");
|
||||
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown;
|
||||
});
|
||||
|
||||
const tiles = $derived(s?.tiles_waiting ?? null);
|
||||
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
|
||||
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`);
|
||||
|
||||
const rp = $derived(launcher.removeProgress);
|
||||
const removeFraction = $derived(rp === null || rp.total === 0 ? null : Math.min(rp.done / rp.total, 1));
|
||||
|
||||
const tp = $derived(launcher.tileProgress);
|
||||
const tileMeta = $derived(tp === null ? null : `${formatCount(tp.tiles)} tiles`);
|
||||
const tileFraction = $derived(tp === null || tp.page_tiles === 0 ? null : tp.page_done / tp.page_tiles);
|
||||
|
||||
const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error));
|
||||
</script>
|
||||
|
||||
@@ -182,6 +209,29 @@
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if unfinished}
|
||||
<p class="secondary attention">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>
|
||||
<strong>{unfinished.items}</strong>
|
||||
{unfinished.items === 1 ? "chosen map is" : "chosen maps are"} only partly downloaded
|
||||
{#if unfinishedNames}<span class="dim">({unfinishedNames})</span>{/if}.
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.finishMarket()}>Finish</button>
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if tilesOffered}
|
||||
<!-- Ground another operator looked at while this PC was away. Online it is already
|
||||
on screen, drawn from {site}; nothing keeps it, so unplugging loses a week of
|
||||
other people's map. -->
|
||||
<p class="secondary attention">
|
||||
<Icon name="download" size={14} />
|
||||
<span>
|
||||
<strong>{tilesCount}</strong> new map tiles on {site} ({formatBytes(tiles?.bytes ?? 0)}).
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Take them</button>
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
{:else if action === "installing"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
@@ -236,6 +286,26 @@
|
||||
<ProgressBar value={null} label="Syncing with the DHP" />
|
||||
</div>
|
||||
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
|
||||
{:else if action === "removing"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
<span class="stage-name">Removing {rp?.title ?? "what you unticked"}</span>
|
||||
{#if removeFraction !== null}<span class="pct mono tabular">{Math.floor(removeFraction * 100)}%</span>{/if}
|
||||
</div>
|
||||
<ProgressBar value={removeFraction} label="Removing" />
|
||||
{#if rp}<p class="message mono">{formatCount(rp.done)} / {formatCount(rp.total)} files</p>{/if}
|
||||
</div>
|
||||
<p class="secondary">Deleting a map is hundreds of thousands of files. Nothing is downloaded meanwhile.</p>
|
||||
{:else if action === "pulling_tiles"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
<span class="stage-name">Taking map tiles from {site}</span>
|
||||
{#if tp}<span class="pct mono tabular">{formatBytes(tp.bytes)}</span>{/if}
|
||||
</div>
|
||||
<ProgressBar value={tileFraction} label="Taking map tiles" />
|
||||
</div>
|
||||
<p class="secondary">Tiles the other operators baked while this PC was away. It can be stopped and resumed.</p>
|
||||
<button class="btn wide" onclick={() => launcher.cancelInstall()}>Stop</button>
|
||||
{:else if action === "stack"}
|
||||
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
||||
{#if s?.map_from_site === false}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { backend } from "../lib/backend";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
import { errorText, formatBytes } from "../lib/format";
|
||||
import type { Market, MarketItem } from "../lib/types";
|
||||
import { errorText, formatBytes, formatCount } from "../lib/format";
|
||||
import type { Market, MarketCheck, MarketItem } from "../lib/types";
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let market = $state<Market | null>(null);
|
||||
@@ -11,20 +12,55 @@
|
||||
let loadError = $state<string | null>(null);
|
||||
/** The optional items ticked here, by key: the draft the operator applies. */
|
||||
let chosen = $state<string[]>([]);
|
||||
/** What a count of the files on disk found, once it has been taken. */
|
||||
let checks = $state<Record<string, MarketCheck> | null>(null);
|
||||
let saved = $state(false);
|
||||
let applyError = $state<string | null>(null);
|
||||
/** Apply is waiting on a yes: it would delete something. */
|
||||
let confirming = $state(false);
|
||||
let cancelButton: HTMLButtonElement | undefined = $state();
|
||||
|
||||
const s = $derived(launcher.status);
|
||||
const locked = $derived(launcher.locked);
|
||||
/** Set while the panel describes a drive to install from, instead of this PC and its DHP. */
|
||||
const drive = $derived(launcher.marketDrive);
|
||||
const siteName = $derived(launcher.siteName);
|
||||
const applying = $derived(launcher.busy === "market");
|
||||
const busy = $derived(launcher.busy !== null);
|
||||
|
||||
const required = $derived(market?.items.filter((item) => item.required) ?? []);
|
||||
const optional = $derived(market?.items.filter((item) => !item.required) ?? []);
|
||||
const picked = (item: MarketItem) => chosen.includes(item.key);
|
||||
const dirty = $derived(optional.some((item) => picked(item) !== item.selected));
|
||||
/**
|
||||
* What is REALLY there, once counted.
|
||||
*
|
||||
* An item's badge comes from the installer's own marker - its note that it finished. Any
|
||||
* emptying of the folder afterwards leaves that note behind, and the marketplace goes on
|
||||
* calling it installed. The count is a walk of hundreds of thousands of files, far too
|
||||
* slow to open on, so the drawer opens on the marker and corrects itself when the count
|
||||
* comes back.
|
||||
*/
|
||||
const shortfall = (item: MarketItem): MarketCheck | null => {
|
||||
const check = checks?.[item.key];
|
||||
return check && !check.whole ? check : null;
|
||||
};
|
||||
const counting = $derived(checks === null && market !== null && drive === null);
|
||||
/** What the badge should say, marker and disk together. */
|
||||
const presenceOf = (item: MarketItem) =>
|
||||
item.presence === "installed" && shortfall(item) !== null ? "partial" : item.presence;
|
||||
/**
|
||||
* What this PC has settled on: what the operator chose, PLUS whatever is actually here.
|
||||
*
|
||||
* `selected` answers only "did the operator ask for this", and an item can be installed
|
||||
* without ever having been asked for - required by the version that installed it (Dark,
|
||||
* until Dark V2 took its place), taken off a drive, or never downloadable at all. Opening
|
||||
* on `selected` alone shows those unticked though installed, which this panel states as
|
||||
* "removed from this PC, frees N GB" and acts on at the next Apply. Nobody asked for that.
|
||||
*
|
||||
* It is the OPENING position, not a veto: unticking works exactly as before.
|
||||
*/
|
||||
const held = (item: MarketItem) => item.selected || item.presence === "installed";
|
||||
const dirty = $derived(optional.some((item) => picked(item) !== held(item)));
|
||||
const adds = $derived(optional.filter((item) => picked(item) && item.presence !== "installed"));
|
||||
const removes = $derived(optional.filter((item) => !picked(item) && item.presence !== "absent"));
|
||||
const installed = $derived((market?.installed ?? false) && drive === null);
|
||||
@@ -49,6 +85,8 @@
|
||||
const updateFirst = $derived(drive === null && installed && (s?.update_required ?? false));
|
||||
/** A stopped download is work to do even when the choice did not change. */
|
||||
const work = $derived(installed ? adds.length + removes.length > 0 : dirty);
|
||||
/** Removed items no download can bring back: only an ODIAN drive carries them. */
|
||||
const gone = $derived(removes.filter((item) => !item.on_dhp));
|
||||
// A copy from a drive starts here, so a disk that cannot hold it stops the button too.
|
||||
const blocked = $derived(locked || busy || offlineAdds || updateFirst || ((installed || drive !== null) && tooBig));
|
||||
|
||||
@@ -58,7 +96,9 @@
|
||||
try {
|
||||
const next = await launcher.loadMarket(drive ?? undefined);
|
||||
market = next;
|
||||
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
|
||||
chosen = next.items.filter((item) => !item.required && held(item)).map((item) => item.key);
|
||||
// Not awaited: the list is usable at once, and the badges settle when the count lands.
|
||||
if (drive === null) void count();
|
||||
} catch (e) {
|
||||
loadError = errorText(e);
|
||||
} finally {
|
||||
@@ -66,20 +106,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Weigh what the markers claim. Slow, so it runs behind the open list. */
|
||||
async function count() {
|
||||
checks = null;
|
||||
try {
|
||||
const found = await backend.checkMarket();
|
||||
checks = Object.fromEntries(found.map((check) => [check.key, check]));
|
||||
} catch {
|
||||
// A count that cannot be taken leaves the markers' word standing, which is what the
|
||||
// marketplace showed before there was a count at all.
|
||||
checks = {};
|
||||
}
|
||||
}
|
||||
|
||||
function revert() {
|
||||
chosen = optional.filter((item) => item.selected).map((item) => item.key);
|
||||
chosen = optional.filter((item) => held(item)).map((item) => item.key);
|
||||
saved = false;
|
||||
applyError = null;
|
||||
confirming = false;
|
||||
}
|
||||
|
||||
function toggle(item: MarketItem) {
|
||||
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
|
||||
saved = false;
|
||||
applyError = null;
|
||||
// The question was asked about a list that no longer holds.
|
||||
confirming = false;
|
||||
}
|
||||
|
||||
// The question replaces the button that asked it, so focus would fall to the document
|
||||
// and Enter would do nothing at all. It lands on Cancel: the safe answer, and the one a
|
||||
// keyboard reaches first.
|
||||
$effect(() => {
|
||||
if (confirming) cancelButton?.focus();
|
||||
});
|
||||
|
||||
// Escape while the question is up answers "no", and nothing more. Left alone it closes
|
||||
// the whole drawer - which looks exactly like the removal having been accepted, on the
|
||||
// one screen where that guess costs 705 GB. Taken on the KEYDOWN rather than the dialog's
|
||||
// `cancel` event: closing on Escape is that key's default action, and `cancel` does not
|
||||
// bubble, so a handler on it is the less certain of the two.
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
if (!el) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || !confirming) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
confirming = false;
|
||||
};
|
||||
el.addEventListener("keydown", onKey, true);
|
||||
return () => el.removeEventListener("keydown", onKey, true);
|
||||
});
|
||||
|
||||
async function apply() {
|
||||
if (blocked) return;
|
||||
// Deleting is the one thing here that cannot be taken back by pressing the button
|
||||
// again: a re-download is hours, and for an item the DHP does not carry there is no
|
||||
// re-download at all. Adding needs no ceremony, so only a removal is asked about.
|
||||
if (drive === null && installed && removes.length > 0 && !confirming) {
|
||||
confirming = true;
|
||||
return;
|
||||
}
|
||||
confirming = false;
|
||||
// From a drive: save the choice, then copy. The progress shows in the main panel.
|
||||
if (drive !== null) {
|
||||
if (!(await launcher.setMarket([...chosen], drive))) {
|
||||
@@ -107,17 +196,29 @@
|
||||
|
||||
/** True while the DHP would draw this item itself, so the PC lacking it shows nothing. */
|
||||
const drawnBySite = (item: MarketItem) =>
|
||||
item.presence === "absent" && drive === null && (s?.online ?? false);
|
||||
item.presence === "absent" && drive === null && (s?.online ?? false) && item.on_dhp;
|
||||
|
||||
// Listed, but the DHP has not got it: the terrain source is 705 GB and comes on a drive.
|
||||
// Only true in the DHP's own marketplace - a drive that carries it is offering it - and
|
||||
// only while this PC does NOT hold it. Once it is here, where it came from is history:
|
||||
// saying "not on the DHP" beside "Installed" reads as a fault instead of a fact.
|
||||
const offDhp = (item: MarketItem) => !item.on_dhp && drive === null && item.presence !== "installed";
|
||||
|
||||
function presenceLabel(item: MarketItem): string {
|
||||
if (item.presence === "installed") return "Installed";
|
||||
if (item.presence === "partial") return "Partly downloaded";
|
||||
const presence = presenceOf(item);
|
||||
if (presence === "installed") return counting ? "Installed, checking…" : "Installed";
|
||||
if (presence === "partial") return "Partly downloaded";
|
||||
// "Not installed" would read as missing when nothing is missing: while the DHP
|
||||
// answers it draws this itself. What this PC lacks is the copy that works offline.
|
||||
return drawnBySite(item) ? "On the DHP" : "Not installed";
|
||||
}
|
||||
|
||||
function presenceHelp(item: MarketItem): string | undefined {
|
||||
const short = shortfall(item);
|
||||
if (short) {
|
||||
return `Its marker says it is installed, but only ${formatCount(short.found)} of ${formatCount(short.expected)} files are on this PC. Re-applying downloads the rest.`;
|
||||
}
|
||||
if (offDhp(item)) return "Your DHP does not carry this one. It is installed from an ODIAN drive.";
|
||||
if (item.presence !== "absent" || drive !== null) return undefined;
|
||||
return drawnBySite(item)
|
||||
? "Your DHP draws this while you are connected. Download it to keep it when you are not."
|
||||
@@ -249,7 +350,7 @@
|
||||
type="checkbox"
|
||||
class="check"
|
||||
checked={picked(item)}
|
||||
disabled={locked || applying}
|
||||
disabled={locked || applying || offDhp(item)}
|
||||
onchange={() => toggle(item)}
|
||||
/>
|
||||
<span class="body">
|
||||
@@ -259,9 +360,12 @@
|
||||
</span>
|
||||
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
|
||||
<span class="pill {presenceOf(item)}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
|
||||
{presenceLabel(item)}
|
||||
</span>
|
||||
{#if offDhp(item)}
|
||||
<span class="pill off-dhp" title={presenceHelp(item)}>Not on the DHP</span>
|
||||
{/if}
|
||||
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
@@ -287,6 +391,39 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirming}
|
||||
<section class="confirm" role="alert">
|
||||
<h3 class="confirm-head">
|
||||
<Icon name="alert" size={15} />
|
||||
Remove {removes.length === 1 ? "this" : `these ${removes.length}`} from this PC?
|
||||
</h3>
|
||||
<ul class="confirm-list">
|
||||
{#each removes as item (item.key)}
|
||||
<li>
|
||||
<span class="name">{item.title}</span>
|
||||
<span class="size">{formatBytes(item.bytes)}</span>
|
||||
{#if !item.on_dhp}<span class="pill off-dhp">Only from a drive</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="confirm-note">
|
||||
Their files are deleted from this PC, freeing {formatBytes(removeBytes)}.
|
||||
{#if gone.length}
|
||||
<strong>
|
||||
{gone.length === 1 ? `${gone[0].title} is not on ${siteName}` : `${gone.length} of them are not on ${siteName}`}:
|
||||
once removed, only an ODIAN drive can bring {gone.length === 1 ? "it" : "them"} back.
|
||||
</strong>
|
||||
{:else}
|
||||
Taking {removes.length === 1 ? "it" : "them"} again later means downloading
|
||||
{formatBytes(removeBytes)} from {siteName}.
|
||||
{/if}
|
||||
{#if adds.length}
|
||||
 · {adds.length === 1 ? "One item" : `${adds.length} items`} will be downloaded in the same pass.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<footer class="foot">
|
||||
<span class="summary" role="status">
|
||||
{#if applyError}
|
||||
@@ -310,15 +447,28 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-accent"
|
||||
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
|
||||
onclick={apply}
|
||||
>
|
||||
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
|
||||
</button>
|
||||
{#if confirming}
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
bind:this={cancelButton}
|
||||
disabled={applying}
|
||||
onclick={() => (confirming = false)}>Cancel</button
|
||||
>
|
||||
<button type="button" class="btn btn-danger" disabled={blocked} onclick={apply}>
|
||||
{applying ? "Removing…" : `Remove ${removes.length === 1 ? "it" : "them"}`}
|
||||
</button>
|
||||
{:else}
|
||||
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-accent"
|
||||
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
|
||||
onclick={apply}
|
||||
>
|
||||
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -552,6 +702,11 @@
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
/* The DHP has not got this one. Not a fault either - it comes on a drive - so it is
|
||||
stated flatly rather than coloured like something that went wrong. */
|
||||
.pill.off-dhp {
|
||||
border-style: dashed;
|
||||
}
|
||||
.change {
|
||||
color: var(--accent-hover);
|
||||
font-size: var(--text-xs);
|
||||
@@ -579,6 +734,46 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* The question, shown where the answer is given: directly above the two buttons. */
|
||||
.confirm {
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
}
|
||||
.confirm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.confirm-list {
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.confirm-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.confirm-list .size {
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.confirm-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.confirm-note strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user