Files
ODIAN_Launcher/src/components/SettingsDrawer.svelte
T
2026-09-29 15:54:26 +02:00

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>