519 lines
16 KiB
Svelte
519 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from "svelte";
|
|
import Icon from "./Icon.svelte";
|
|
import { launcher } from "../lib/launcher.svelte";
|
|
import type { Settings } from "../lib/types";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
}
|
|
|
|
let { open = $bindable(false) }: Props = $props();
|
|
|
|
const EMPTY: Settings = {
|
|
site_url: "",
|
|
mcp_url: "",
|
|
ai_url: "",
|
|
install_dir: "",
|
|
dev_root: "",
|
|
dev_mode: false,
|
|
dev_user: "",
|
|
dev_build: false,
|
|
dev_target: "client",
|
|
};
|
|
|
|
let dialog: HTMLDialogElement | undefined = $state();
|
|
let draft = $state<Settings>({ ...EMPTY });
|
|
let saved = $state(false);
|
|
let saveError = $state<string | null>(null);
|
|
/** Sign out forgets the operator on this PC, so it takes a second, explicit click. */
|
|
let confirmSignOut = $state(false);
|
|
|
|
const s = $derived(launcher.status);
|
|
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>({
|
|
site_url: draft.site_url.trim(),
|
|
mcp_url: draft.mcp_url.trim(),
|
|
ai_url: draft.ai_url.trim(),
|
|
install_dir: draft.install_dir.trim(),
|
|
dev_root: draft.dev_root.trim(),
|
|
dev_mode: draft.dev_mode,
|
|
dev_user: draft.dev_user.trim(),
|
|
dev_build: draft.dev_build,
|
|
dev_target: draft.dev_target,
|
|
});
|
|
const dirty = $derived(
|
|
current !== null &&
|
|
(trimmed.site_url !== current.site_url ||
|
|
trimmed.mcp_url !== current.mcp_url ||
|
|
trimmed.ai_url !== current.ai_url ||
|
|
trimmed.install_dir !== current.install_dir ||
|
|
trimmed.dev_root !== current.dev_root ||
|
|
trimmed.dev_mode !== current.dev_mode ||
|
|
trimmed.dev_user !== current.dev_user ||
|
|
trimmed.dev_build !== current.dev_build ||
|
|
trimmed.dev_target !== current.dev_target),
|
|
);
|
|
/** 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));
|
|
// Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is
|
|
// how a PC is put fully offline on purpose - and how an offline site is rehearsed on a
|
|
// PC that does have a line out.
|
|
const siteError = $derived(trimmed.site_url === "" ? null : urlProblem(trimmed.site_url));
|
|
const mcpError = $derived(trimmed.mcp_url === "" ? null : urlProblem(trimmed.mcp_url));
|
|
/** Optional: an empty AI address is how an install with no line out is left. */
|
|
const aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
|
|
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
|
const invalid = $derived(
|
|
siteError !== null || mcpError !== null || aiError !== null || dirError !== null || offDrive,
|
|
);
|
|
|
|
function urlProblem(value: string): string | null {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address.";
|
|
} catch {
|
|
return "Enter a full address, including http:// or https://.";
|
|
}
|
|
}
|
|
|
|
function resetDraft() {
|
|
draft = current ? { ...current } : { ...EMPTY };
|
|
saved = false;
|
|
saveError = null;
|
|
confirmSignOut = false;
|
|
}
|
|
|
|
// Keep the native modal in sync with `open`, returning focus to whatever opened it.
|
|
let opener: HTMLElement | null = null;
|
|
$effect(() => {
|
|
const el = dialog;
|
|
if (!el) return;
|
|
if (open && !el.open) {
|
|
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
untrack(resetDraft);
|
|
el.showModal();
|
|
} else if (!open) {
|
|
if (el.open) el.close();
|
|
if (opener?.isConnected) opener.focus();
|
|
opener = null;
|
|
}
|
|
});
|
|
|
|
// Settings may arrive after the drawer was opened.
|
|
$effect(() => {
|
|
if (open && current && untrack(() => !draft.site_url && !draft.mcp_url && !draft.install_dir)) {
|
|
untrack(resetDraft);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const el = dialog;
|
|
if (!el) return;
|
|
// Clicks on the ::backdrop target the dialog element itself.
|
|
const onClick = (e: MouseEvent) => {
|
|
if (e.target === el) open = false;
|
|
};
|
|
// The native cancel-on-Escape is skipped for some synthetic key events; be explicit.
|
|
const onKeydown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
open = false;
|
|
}
|
|
};
|
|
el.addEventListener("click", onClick);
|
|
el.addEventListener("keydown", onKeydown);
|
|
return () => {
|
|
el.removeEventListener("click", onClick);
|
|
el.removeEventListener("keydown", onKeydown);
|
|
};
|
|
});
|
|
|
|
async function save(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (!dirty || invalid || locked) return;
|
|
const ok = await launcher.saveSettings({ ...trimmed });
|
|
if (ok) {
|
|
resetDraft();
|
|
saved = true;
|
|
} else {
|
|
saveError = launcher.notice;
|
|
}
|
|
}
|
|
|
|
/** Keyboard focus for a control that replaces the one the operator just used. */
|
|
function focusOnMount(node: HTMLElement) {
|
|
node.focus();
|
|
}
|
|
|
|
async function signOutAndForget() {
|
|
confirmSignOut = false;
|
|
await launcher.signOut();
|
|
// The button that had focus is gone; put focus back inside the drawer so Escape still closes it.
|
|
dialog?.querySelector<HTMLButtonElement>("button:not([disabled])")?.focus();
|
|
}
|
|
|
|
// One stack either way: the web client talks to the same API and database as the .exe,
|
|
// which is the whole reason running both at once is worth offering.
|
|
const TARGETS = [
|
|
{ key: "client", name: "ODIAN", note: "Odian.exe from the checkout" },
|
|
{ key: "web", name: "Web", note: "the _WEB dev server on :5173" },
|
|
{ key: "both", name: "Both", note: "one stack, two clients, same data" },
|
|
] as const;
|
|
|
|
async function browseDev() {
|
|
const path = await launcher.pickFolder();
|
|
if (path) draft.dev_root = path;
|
|
}
|
|
|
|
async function browse() {
|
|
const path = await launcher.pickFolder();
|
|
if (path) draft.install_dir = path;
|
|
}
|
|
|
|
function installFromDrive() {
|
|
open = false;
|
|
void launcher.chooseDrive();
|
|
}
|
|
|
|
function signIn() {
|
|
open = false;
|
|
void launcher.signIn();
|
|
}
|
|
</script>
|
|
|
|
<dialog bind:this={dialog} class="drawer" aria-labelledby="settings-title" onclose={() => (open = false)}>
|
|
<form class="panel" onsubmit={save} novalidate>
|
|
<header class="head">
|
|
<h2 id="settings-title">Settings</h2>
|
|
<button type="button" class="icon-btn" aria-label="Close settings" onclick={() => (open = false)}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="content">
|
|
{#if locked}
|
|
<p class="lock" role="note">
|
|
<Icon name="alert" size={14} />
|
|
Settings are locked while ODIAN is signing in, installing or running.
|
|
</p>
|
|
{/if}
|
|
|
|
<fieldset class="group" disabled={locked || saving}>
|
|
<legend class="eyebrow">DHP</legend>
|
|
<div class="field">
|
|
<label class="field-label" for="set-site-url">DHP URL</label>
|
|
<input
|
|
id="set-site-url"
|
|
class="input"
|
|
type="url"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
bind:value={draft.site_url}
|
|
aria-invalid={dirty && siteError !== null}
|
|
aria-describedby="set-site-url-help"
|
|
/>
|
|
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-help"}>
|
|
{dirty && siteError ? siteError : "Update service of your DHP. Leave empty to run offline."}
|
|
</span>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field-label" for="set-mcp-url">MCP URL</label>
|
|
<input
|
|
id="set-mcp-url"
|
|
class="input"
|
|
type="url"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
bind:value={draft.mcp_url}
|
|
aria-invalid={dirty && mcpError !== null}
|
|
aria-describedby="set-mcp-url-help"
|
|
/>
|
|
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-help"}>
|
|
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in. Leave empty to run offline."}
|
|
</span>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field-label" for="set-ai-url">AI URL</label>
|
|
<input
|
|
id="set-ai-url"
|
|
class="input"
|
|
type="url"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
placeholder="https://engine.odian.fr"
|
|
bind:value={draft.ai_url}
|
|
aria-invalid={dirty && aiError !== null}
|
|
aria-describedby="set-ai-url-help"
|
|
/>
|
|
<span id="set-ai-url-help" class={dirty && aiError ? "field-error" : "field-help"}>
|
|
{dirty && aiError
|
|
? aiError
|
|
: "Destination prediction, chat and vision, served over the internet. Leave empty and ODIAN runs wholly offline."}
|
|
</span>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="group" disabled={locked || saving}>
|
|
<legend class="eyebrow">Installation</legend>
|
|
<div class="field">
|
|
<label class="field-label" for="set-install-dir">Install folder</label>
|
|
<div class="row">
|
|
<input
|
|
id="set-install-dir"
|
|
class="input"
|
|
type="text"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
bind:value={draft.install_dir}
|
|
aria-invalid={dirty && dirError !== null}
|
|
aria-describedby="set-install-dir-help"
|
|
/>
|
|
<button type="button" class="btn" onclick={browse}>
|
|
<Icon name="folder" size={14} /> Browse
|
|
</button>
|
|
</div>
|
|
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
|
|
{#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">
|
|
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…
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="group" aria-labelledby="set-account-title">
|
|
<h3 id="set-account-title" class="eyebrow">Account</h3>
|
|
{#if s?.signed_in && s.operator}
|
|
<p class="text">
|
|
Signed in as <strong>{s.operator}</strong> on {launcher.siteName}. This PC remembers you until you sign out.
|
|
</p>
|
|
{#if confirmSignOut}
|
|
<p class="text" role="alert" style="color: var(--warn)">
|
|
Signing out makes this PC forget <strong>{s.operator}</strong>: ODIAN won't launch offline until someone
|
|
signs in again while connected to the DHP.
|
|
</p>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px">
|
|
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={signOutAndForget}>
|
|
<Icon name="signout" size={14} /> Sign out and forget me
|
|
</button>
|
|
<!-- The safe choice takes focus, so Enter right after "Sign out" keeps the operator signed in. -->
|
|
<button type="button" class="btn" use:focusOnMount onclick={() => (confirmSignOut = false)}>
|
|
Keep me signed in
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={() => (confirmSignOut = true)}>
|
|
<Icon name="signout" size={14} /> Sign out
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<p class="text">
|
|
{#if s?.operator}
|
|
Not signed in. Last operator: <strong>{s.operator}</strong>{s.online ? "" : " (offline)"}.
|
|
{:else}
|
|
Not signed in.
|
|
{/if}
|
|
{#if s && !s.online}Signing in needs a connection to your DHP.{/if}
|
|
</p>
|
|
{#if s?.online}
|
|
<div>
|
|
<button type="button" class="btn" disabled={locked || busy} onclick={signIn}>
|
|
<Icon name="key" size={14} /> Sign in
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
|
|
<footer class="foot">
|
|
<span class="result" role="status">
|
|
{#if saveError}
|
|
<span class="err">{saveError}</span>
|
|
{:else if saved}
|
|
<Icon name="check" size={14} /> Saved
|
|
{/if}
|
|
</span>
|
|
<button type="button" class="btn" disabled={!dirty || saving} onclick={resetDraft}>Revert</button>
|
|
<button type="submit" class="btn btn-accent" disabled={!dirty || invalid || locked || saving}>
|
|
{saving ? "Saving…" : "Save changes"}
|
|
</button>
|
|
</footer>
|
|
</form>
|
|
</dialog>
|
|
|
|
<style>
|
|
.drawer {
|
|
position: fixed;
|
|
inset: 0 0 0 auto;
|
|
width: min(440px, 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);
|
|
}
|
|
.head h2 {
|
|
font-family: var(--font-display);
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.lock {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin: 16px 24px 0;
|
|
padding: 8px 12px;
|
|
border: 1px solid rgba(245, 180, 84, 0.3);
|
|
border-radius: var(--radius);
|
|
background: var(--warn-soft);
|
|
color: var(--warn);
|
|
font-size: var(--text-xs);
|
|
}
|
|
|
|
.group {
|
|
display: grid;
|
|
gap: 14px;
|
|
min-width: 0;
|
|
margin: 0;
|
|
padding: 22px 24px 24px;
|
|
border: 0;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.group legend {
|
|
float: left;
|
|
width: 100%;
|
|
margin: 0 0 14px;
|
|
padding: 0;
|
|
}
|
|
.group legend + * {
|
|
clear: both;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.text {
|
|
color: var(--muted);
|
|
font-size: var(--text-sm);
|
|
line-height: 1.55;
|
|
}
|
|
.text strong {
|
|
color: var(--ink);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.result {
|
|
display: flex;
|
|
flex: 1;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
color: var(--ok);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.err {
|
|
overflow: hidden;
|
|
color: var(--danger);
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
</style>
|