update
This commit is contained in:
@@ -98,7 +98,7 @@
|
||||
{#snippet driveLink(lead: string)}
|
||||
<p class="secondary">
|
||||
{lead}
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.installFromDrive()}>Install from a drive…</button>
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.chooseDrive()}>Install from a drive…</button>
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
| "check"
|
||||
| "signout"
|
||||
| "key"
|
||||
| "globe";
|
||||
| "globe"
|
||||
| "market";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -92,6 +93,11 @@
|
||||
<circle cx="8" cy="8" r="5.5" />
|
||||
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
|
||||
<path d="M2.5 8h11" />
|
||||
{:else if name === "market"}
|
||||
<rect x="2.5" y="2.5" width="4.5" height="4.5" />
|
||||
<rect x="9" y="2.5" width="4.5" height="4.5" />
|
||||
<rect x="2.5" y="9" width="4.5" height="4.5" />
|
||||
<path d="M11.25 9v4.5M9 11.25h4.5" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
import { errorText, formatBytes } from "../lib/format";
|
||||
import type { Market, MarketItem } from "../lib/types";
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let market = $state<Market | null>(null);
|
||||
let loading = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
/** The optional items ticked here, by key: the draft the operator applies. */
|
||||
let chosen = $state<string[]>([]);
|
||||
let saved = $state(false);
|
||||
let applyError = $state<string | null>(null);
|
||||
|
||||
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 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));
|
||||
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);
|
||||
const addBytes = $derived(adds.reduce((sum, item) => sum + item.bytes, 0));
|
||||
const removeBytes = $derived(removes.reduce((sum, item) => sum + item.bytes, 0));
|
||||
/** Before the first install: everything Install will download with this choice. */
|
||||
const installBytes = $derived(
|
||||
(market?.core_bytes ?? 0) +
|
||||
required.reduce((sum, item) => sum + item.bytes, 0) +
|
||||
optional.filter(picked).reduce((sum, item) => sum + item.bytes, 0),
|
||||
);
|
||||
/** A copy skips what the target already holds, byte for byte: only the rest crosses. */
|
||||
const copyBytes = $derived(
|
||||
(market?.core_bytes ?? 0) +
|
||||
(market?.items
|
||||
.filter((item) => (item.required || picked(item)) && item.presence !== "installed")
|
||||
.reduce((sum, item) => sum + item.bytes, 0) ?? 0),
|
||||
);
|
||||
const needBytes = $derived(drive !== null ? copyBytes : installed ? addBytes : installBytes);
|
||||
const tooBig = $derived(market?.free_bytes != null && needBytes > market.free_bytes);
|
||||
const offlineAdds = $derived(drive === null && installed && adds.length > 0 && !s?.online);
|
||||
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);
|
||||
// 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));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const next = await launcher.loadMarket(drive ?? undefined);
|
||||
market = next;
|
||||
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
|
||||
} catch (e) {
|
||||
loadError = errorText(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function revert() {
|
||||
chosen = optional.filter((item) => item.selected).map((item) => item.key);
|
||||
saved = false;
|
||||
applyError = null;
|
||||
}
|
||||
|
||||
function toggle(item: MarketItem) {
|
||||
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
|
||||
saved = false;
|
||||
applyError = null;
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (blocked) return;
|
||||
// From a drive: save the choice, then copy. The progress shows in the main panel.
|
||||
if (drive !== null) {
|
||||
if (!(await launcher.setMarket([...chosen], drive))) {
|
||||
applyError = launcher.notice;
|
||||
return;
|
||||
}
|
||||
launcher.marketOpen = false;
|
||||
void launcher.installFromDrive(drive);
|
||||
return;
|
||||
}
|
||||
if (!work) return;
|
||||
const startsDownload = installed && work;
|
||||
const ok = await launcher.applyMarket([...chosen]);
|
||||
if (!ok) {
|
||||
applyError = launcher.notice;
|
||||
return;
|
||||
}
|
||||
if (startsDownload) {
|
||||
launcher.marketOpen = false;
|
||||
} else {
|
||||
saved = true;
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function presenceLabel(item: MarketItem): string {
|
||||
if (item.presence === "installed") return "Installed";
|
||||
if (item.presence === "partial") return "Partly downloaded";
|
||||
return "Not installed";
|
||||
}
|
||||
|
||||
function changeNote(item: MarketItem): string | null {
|
||||
if (drive !== null) {
|
||||
if (!picked(item)) return item.presence === "absent" ? null : "Left as it is";
|
||||
if (item.presence === "installed") return "Already here";
|
||||
return item.presence === "partial" ? "Copy resumes" : `Copies ${formatBytes(item.bytes)}`;
|
||||
}
|
||||
if (!installed) return picked(item) ? "Installed with ODIAN" : null;
|
||||
if (picked(item) && item.presence === "partial") return "Download resumes";
|
||||
if (picked(item) && item.presence === "absent") return `Downloads ${formatBytes(item.bytes)}`;
|
||||
if (!picked(item) && item.presence !== "absent") return `Removed from this PC, frees ${formatBytes(item.bytes)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the native modal in sync with the store, returning focus to whatever opened it.
|
||||
const open = $derived(launcher.marketOpen);
|
||||
let opener: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
void drive;
|
||||
if (!el) return;
|
||||
if (open && !el.open) {
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
saved = false;
|
||||
applyError = null;
|
||||
untrack(() => void load());
|
||||
el.showModal();
|
||||
} else if (!open) {
|
||||
if (el.open) el.close();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
opener = null;
|
||||
}
|
||||
});
|
||||
|
||||
// A marketplace download that finishes while the drawer is open changes what is installed.
|
||||
let lastPhase: string | undefined;
|
||||
$effect(() => {
|
||||
const phase = s?.phase;
|
||||
if (open && lastPhase && lastPhase !== phase && phase === "idle") untrack(() => void load());
|
||||
lastPhase = phase;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
if (!el) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.target === el) launcher.marketOpen = false;
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
launcher.marketOpen = false;
|
||||
}
|
||||
};
|
||||
el.addEventListener("click", onClick);
|
||||
el.addEventListener("keydown", onKeydown);
|
||||
return () => {
|
||||
el.removeEventListener("click", onClick);
|
||||
el.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class="drawer" aria-labelledby="market-title" onclose={() => (launcher.marketOpen = false)}>
|
||||
<div class="panel">
|
||||
<header class="head">
|
||||
<div class="title">
|
||||
<h2 id="market-title">{drive === null ? "Marketplace" : "Install from drive"}</h2>
|
||||
{#if market}<span class="version">ODIAN {market.version}</span>{/if}
|
||||
</div>
|
||||
<button type="button" class="icon-btn" aria-label="Close marketplace" onclick={() => (launcher.marketOpen = false)}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
{#if locked}
|
||||
<p class="note warn" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
The marketplace is locked while ODIAN is signing in, installing or running.
|
||||
</p>
|
||||
{:else if updateFirst}
|
||||
<p class="note warn" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
Update ODIAN first: the marketplace adds to the installed version.
|
||||
</p>
|
||||
{:else if offlineAdds}
|
||||
<p class="note warn" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
Your DHP is unreachable: connect to download. Removing items works offline.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && !market}
|
||||
<p class="empty">Reading what your DHP offers…</p>
|
||||
{:else if loadError}
|
||||
<div class="empty">
|
||||
<p class="err-text">{loadError}</p>
|
||||
<button type="button" class="btn" onclick={() => void load()}>
|
||||
<Icon name="refresh" size={14} /> Try again
|
||||
</button>
|
||||
</div>
|
||||
{:else if market}
|
||||
{#if drive !== null}
|
||||
<p class="intro">
|
||||
Copying from <strong>{drive}</strong> into <strong>{s?.install_dir}</strong>. Only what the drive
|
||||
carries is listed, and every file is checked as it lands.
|
||||
</p>
|
||||
{:else if !installed}
|
||||
<p class="intro">
|
||||
ODIAN is not installed yet. Choose what to add: <strong>Install</strong> downloads it with the rest.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<section class="group" aria-labelledby="market-optional">
|
||||
<h3 id="market-optional" class="eyebrow">Optional</h3>
|
||||
{#if optional.length === 0}
|
||||
<p class="text">This version offers nothing optional: everything comes with the install.</p>
|
||||
{/if}
|
||||
{#each optional as item (item.key)}
|
||||
{@const note = changeNote(item)}
|
||||
<label class="card" class:picked={picked(item)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="check"
|
||||
checked={picked(item)}
|
||||
disabled={locked || applying}
|
||||
onchange={() => toggle(item)}
|
||||
/>
|
||||
<span class="body">
|
||||
<span class="line">
|
||||
<span class="name">{item.title}</span>
|
||||
<span class="size">{formatBytes(item.bytes)}</span>
|
||||
</span>
|
||||
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="pill {item.presence}">{presenceLabel(item)}</span>
|
||||
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="group" aria-labelledby="market-included">
|
||||
<h3 id="market-included" class="eyebrow">Included with every install</h3>
|
||||
<ul class="included">
|
||||
<li>
|
||||
<span class="name">ODIAN app and database</span>
|
||||
<span class="size">{formatBytes(market.core_bytes)}</span>
|
||||
</li>
|
||||
{#each required as item (item.key)}
|
||||
<li>
|
||||
<span class="name" title={item.description}>{item.title}</span>
|
||||
<span class="size">{formatBytes(item.bytes)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<span class="summary" role="status">
|
||||
{#if applyError}
|
||||
<span class="err">{applyError}</span>
|
||||
{:else if saved}
|
||||
<span class="ok"><Icon name="check" size={14} /> Saved: Install brings your choice.</span>
|
||||
{:else if market}
|
||||
{#if drive !== null}
|
||||
Copies {formatBytes(copyBytes)}
|
||||
{:else if installed}
|
||||
{#if adds.length || removes.length}
|
||||
{#if adds.length}Downloads {formatBytes(addBytes)}{/if}{#if adds.length && removes.length} · {/if}{#if removes.length}Frees {formatBytes(removeBytes)}{/if}
|
||||
{:else}
|
||||
Nothing to change
|
||||
{/if}
|
||||
{:else}
|
||||
Install downloads {formatBytes(installBytes)}
|
||||
{/if}
|
||||
{#if market.free_bytes != null}
|
||||
<span class:err={tooBig}> · {formatBytes(market.free_bytes)} free{tooBig ? ": not enough" : ""}</span>
|
||||
{/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>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(560px, 100vw);
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--line-strong);
|
||||
background: var(--glass-strong);
|
||||
color: var(--ink);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
transition:
|
||||
opacity var(--dur-slow) var(--ease),
|
||||
transform var(--dur-slow) var(--ease),
|
||||
overlay var(--dur-slow) allow-discrete,
|
||||
display var(--dur-slow) allow-discrete;
|
||||
}
|
||||
.drawer[open] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.drawer::backdrop {
|
||||
background: rgba(5, 5, 7, 0);
|
||||
transition:
|
||||
background-color var(--dur-slow) var(--ease),
|
||||
overlay var(--dur-slow) allow-discrete,
|
||||
display var(--dur-slow) allow-discrete;
|
||||
}
|
||||
.drawer[open]::backdrop {
|
||||
background: rgba(5, 5, 7, 0.6);
|
||||
}
|
||||
@starting-style {
|
||||
.drawer[open] {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.drawer[open]::backdrop {
|
||||
background: rgba(5, 5, 7, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 12px 0 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.head h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.version {
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 16px 24px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.note.warn {
|
||||
border: 1px solid rgba(245, 180, 84, 0.3);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.intro,
|
||||
.empty {
|
||||
margin: 20px 24px 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.intro strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
.empty {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.err-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 22px 24px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.group .eyebrow {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.text {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(9, 9, 11, 0.45);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
background-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.card.picked {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.card:has(.check:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
.card:has(.check:focus-visible) {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.name {
|
||||
color: var(--ink);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.size {
|
||||
flex: none;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.desc {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pill {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pill.installed {
|
||||
border-color: rgba(134, 239, 172, 0.28);
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
.pill.partial {
|
||||
border-color: rgba(245, 180, 84, 0.3);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.change {
|
||||
color: var(--accent-hover);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.change.loss {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.included {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.included li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.included .name {
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(9, 9, 11, 0.6);
|
||||
}
|
||||
.summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ok);
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,9 @@
|
||||
const current = $derived(launcher.settings);
|
||||
const locked = $derived(launcher.locked);
|
||||
const saving = $derived(launcher.busy === "settings");
|
||||
/** A launcher that ships on an ODIAN drive installs on that drive to run ODIAN from it,
|
||||
* or on a disk of the PC it is plugged into to hand ODIAN over. */
|
||||
const portable = $derived(s?.portable ?? false);
|
||||
const busy = $derived(launcher.busy !== null);
|
||||
|
||||
const trimmed = $derived<Settings>({
|
||||
@@ -36,10 +39,12 @@
|
||||
trimmed.mcp_url !== current.mcp_url ||
|
||||
trimmed.install_dir !== current.install_dir),
|
||||
);
|
||||
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
|
||||
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
|
||||
const siteError = $derived(urlProblem(trimmed.site_url));
|
||||
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
||||
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
||||
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null);
|
||||
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive);
|
||||
|
||||
function urlProblem(value: string): string | null {
|
||||
if (!value) return "Required.";
|
||||
@@ -134,7 +139,7 @@
|
||||
|
||||
function installFromDrive() {
|
||||
open = false;
|
||||
void launcher.installFromDrive();
|
||||
void launcher.chooseDrive();
|
||||
}
|
||||
|
||||
function signIn() {
|
||||
@@ -216,14 +221,24 @@
|
||||
</button>
|
||||
</div>
|
||||
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
|
||||
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
|
||||
{#if offDrive}
|
||||
Give a folder on a disk, like D:\ODIAN — not a drive's root, and not a network share.
|
||||
{:else if portable}
|
||||
On the ODIAN drive this launcher runs from, ODIAN runs from the drive and follows it to the
|
||||
next PC. A folder on this PC's own disk installs ODIAN onto the PC instead.
|
||||
{:else}
|
||||
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<section class="group" aria-labelledby="set-drive-title">
|
||||
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
|
||||
<p class="text">Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.</p>
|
||||
<p class="text">
|
||||
Copy ODIAN from an ODIAN SSD into your install folder, without reaching the DHP. Pick the drive,
|
||||
then choose what to copy.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
|
||||
<Icon name="drive" size={14} /> Install from drive…
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
onOpenMarket: () => void;
|
||||
}
|
||||
|
||||
let { onOpenSettings }: Props = $props();
|
||||
let { onOpenSettings, onOpenMarket }: Props = $props();
|
||||
|
||||
let maximized = $state(false);
|
||||
|
||||
@@ -81,6 +82,10 @@
|
||||
|
||||
<div class="drag" data-tauri-drag-region></div>
|
||||
|
||||
<button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}>
|
||||
<Icon name="market" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
@@ -223,6 +228,10 @@
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.market {
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.settings {
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user