This commit is contained in:
Kelorastus
2026-09-23 16:05:19 +02:00
parent 4a92cc0b63
commit 4d620fc5ad
27 changed files with 3571 additions and 104 deletions
+1 -1
View File
@@ -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}
+7 -1
View File
@@ -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>
+588
View File
@@ -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}&ensp;·&ensp;{/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}>&ensp;·&ensp;{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>
+19 -4
View File
@@ -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…
+10 -1
View File
@@ -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);
}