update
This commit is contained in:
@@ -238,6 +238,17 @@
|
||||
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
|
||||
{:else if action === "stack"}
|
||||
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
||||
{#if s?.map_from_site === false}
|
||||
<!-- The map is this PC's own. Said plainly, because it looks like nothing is
|
||||
wrong: tiles still draw, they are simply the ones this PC downloaded. -->
|
||||
<div class="notice" role="status">
|
||||
<Icon name="alert" size={14} />
|
||||
<p class="selectable">
|
||||
{site}'s map service is unreachable: drawing from this PC's own tiles.
|
||||
{#if s?.map_problem}<span class="mono dim"> ({s.map_problem})</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
|
||||
<Icon name="stop" size={12} />
|
||||
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
|
||||
@@ -509,6 +520,10 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
.error-box p,
|
||||
.notice .dim {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notice p {
|
||||
flex: 1;
|
||||
color: var(--ink-2);
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
</script>
|
||||
|
||||
<div class="hero">
|
||||
<!-- No wordmark here: the key art behind this panel carries ODIAN's own, and two
|
||||
of them read as a mistake. -->
|
||||
<p class="eyebrow tagline">Tactical map console</p>
|
||||
<h1 class="wordmark">ODIAN</h1>
|
||||
{#if status}
|
||||
<p class="meta mono">
|
||||
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
|
||||
@@ -40,17 +41,6 @@
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0 calc(var(--wordmark-tracking) * -1) 0 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(52px, 5.4vw, 72px);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: var(--wordmark-tracking);
|
||||
text-shadow: 0 0 48px rgba(91, 140, 255, 0.28);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+43
-258
@@ -1,252 +1,16 @@
|
||||
<script lang="ts">
|
||||
// Original key art, generated here: an orthographic wireframe globe with a
|
||||
// bearing ring, a slow orbit, a few track markers and faint topographic
|
||||
// contours. Static except for one orbiting point (off with reduced motion).
|
||||
import { prefersReducedMotion } from "svelte/motion";
|
||||
|
||||
const W = 1280;
|
||||
const H = 760;
|
||||
const CX = 930;
|
||||
const CY = 392;
|
||||
const R = 288;
|
||||
const TILT = rad(24); // view latitude
|
||||
const SPIN = rad(-20); // view longitude
|
||||
|
||||
function rad(deg: number) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function project(latDeg: number, lonDeg: number): Pt {
|
||||
const phi = rad(latDeg);
|
||||
const lambda = rad(lonDeg) - SPIN;
|
||||
const cosPhi = Math.cos(phi);
|
||||
const x = R * cosPhi * Math.sin(lambda);
|
||||
const y = R * (Math.cos(TILT) * Math.sin(phi) - Math.sin(TILT) * cosPhi * Math.cos(lambda));
|
||||
const cosC = Math.sin(TILT) * Math.sin(phi) + Math.cos(TILT) * cosPhi * Math.cos(lambda);
|
||||
return { x: CX + x, y: CY - y, visible: cosC >= 0 };
|
||||
}
|
||||
|
||||
const fmt = (n: number) => n.toFixed(1);
|
||||
|
||||
/** Splits a projected polyline into its near-side and far-side runs. */
|
||||
function trace(points: Pt[]) {
|
||||
let front = "";
|
||||
let back = "";
|
||||
let prev: boolean | null = null;
|
||||
for (const p of points) {
|
||||
const seg = `${prev === p.visible ? "L" : "M"}${fmt(p.x)} ${fmt(p.y)}`;
|
||||
if (p.visible) front += seg;
|
||||
else back += seg;
|
||||
prev = p.visible;
|
||||
}
|
||||
return { front, back };
|
||||
}
|
||||
|
||||
function range(from: number, to: number, step: number) {
|
||||
const out: number[] = [];
|
||||
for (let v = from; v <= to + 1e-9; v += step) out.push(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
...range(-180, 165, 15).map((lon) => trace(range(-90, 90, 2).map((lat) => project(lat, lon)))),
|
||||
...range(-75, 75, 15).map((lat) => trace(range(-180, 180, 2).map((lon) => project(lat, lon)))),
|
||||
];
|
||||
const gridFront = lines.map((l) => l.front).join("");
|
||||
const gridBack = lines.map((l) => l.back).join("");
|
||||
const equator = trace(range(-180, 180, 2).map((lon) => project(0, lon))).front;
|
||||
|
||||
// Bearing ring around the globe.
|
||||
const RING = R + 26;
|
||||
const ticks = range(0, 355, 5)
|
||||
.map((deg) => {
|
||||
const a = rad(deg - 90);
|
||||
const len = deg % 30 === 0 ? 9 : deg % 10 === 0 ? 5 : 2.5;
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
return `M${fmt(CX + c * RING)} ${fmt(CY + s * RING)}L${fmt(CX + c * (RING + len))} ${fmt(CY + s * (RING + len))}`;
|
||||
})
|
||||
.join("");
|
||||
// Labels in the upper-left quadrant would sit under the hero wordmark.
|
||||
const ringLabels = range(0, 330, 30)
|
||||
.filter((deg) => deg < 285 || deg > 345)
|
||||
.map((deg) => {
|
||||
const a = rad(deg - 90);
|
||||
return { x: CX + Math.cos(a) * (RING + 24), y: CY + Math.sin(a) * (RING + 24), text: String(deg).padStart(3, "0") };
|
||||
});
|
||||
|
||||
// Track markers joined by great-circle legs.
|
||||
type Vec = [number, number, number];
|
||||
const toVec = (lat: number, lon: number): Vec => [
|
||||
Math.cos(rad(lat)) * Math.cos(rad(lon)),
|
||||
Math.cos(rad(lat)) * Math.sin(rad(lon)),
|
||||
Math.sin(rad(lat)),
|
||||
];
|
||||
function leg(a: [number, number], b: [number, number]) {
|
||||
const va = toVec(...a);
|
||||
const vb = toVec(...b);
|
||||
const omega = Math.acos(Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
|
||||
const pts: Pt[] = [];
|
||||
for (let i = 0; i <= 48; i++) {
|
||||
const t = i / 48;
|
||||
const ka = Math.sin((1 - t) * omega) / Math.sin(omega);
|
||||
const kb = Math.sin(t * omega) / Math.sin(omega);
|
||||
const v = [0, 1, 2].map((k) => ka * va[k] + kb * vb[k]);
|
||||
const lat = (Math.asin(v[2]) * 180) / Math.PI;
|
||||
const lon = (Math.atan2(v[1], v[0]) * 180) / Math.PI;
|
||||
pts.push(project(lat, lon));
|
||||
}
|
||||
return trace(pts).front;
|
||||
}
|
||||
|
||||
const waypoints: { lat: number; lon: number; label: string }[] = [
|
||||
{ lat: 14, lon: -36, label: "WPT K7" },
|
||||
{ lat: 33, lon: -12, label: "TRK 1187" },
|
||||
{ lat: 47, lon: 5, label: "TRK 0412" },
|
||||
];
|
||||
const markers = waypoints.map((w) => ({ ...w, ...project(w.lat, w.lon) })).filter((m) => m.visible);
|
||||
const route =
|
||||
leg([waypoints[0].lat, waypoints[0].lon], [waypoints[1].lat, waypoints[1].lon]) +
|
||||
leg([waypoints[1].lat, waypoints[1].lon], [waypoints[2].lat, waypoints[2].lon]);
|
||||
|
||||
// Orbit: an inclined ellipse; the far half is hidden behind the globe.
|
||||
const ORBIT_TILT = -14;
|
||||
const RX = R * 1.42;
|
||||
const RY = R * 0.34;
|
||||
const orbitBack = `M${fmt(CX - RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX + RX)} ${CY}`;
|
||||
const orbitFront = `M${fmt(CX + RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX - RX)} ${CY}`;
|
||||
const orbitPath = `${orbitBack}${orbitFront.replace(/^M[^A]+/, "")}`;
|
||||
const staticSat = { x: CX + RX * Math.cos(rad(62)), y: CY + RY * Math.sin(rad(62)) };
|
||||
|
||||
// Topographic contours: nested wobbly rings.
|
||||
function contours(cx: number, cy: number, count: number, seed: number, sx: number, sy: number) {
|
||||
return range(0, count - 1, 1).map((i) => {
|
||||
const pts = range(0, 120, 1).map((k) => {
|
||||
const th = (k / 120) * Math.PI * 2;
|
||||
const r =
|
||||
28 +
|
||||
i * 26 +
|
||||
(10 + i * 1.5) * Math.sin(3 * th + seed + i * 0.35) +
|
||||
(6 + i) * Math.sin(5 * th - seed * 1.7 + i * 0.2) +
|
||||
4 * Math.sin(2 * th + i * 0.5);
|
||||
return `${fmt(cx + r * Math.cos(th) * sx)} ${fmt(cy + r * Math.sin(th) * sy)}`;
|
||||
});
|
||||
return `M${pts.join("L")}Z`;
|
||||
});
|
||||
}
|
||||
const topo = [...contours(520, 780, 12, 1.3, 1.35, 0.72), ...contours(1270, -10, 8, 4.1, 1.1, 0.9)];
|
||||
// The launcher's backdrop: ODIAN's own key art, the picture the product is
|
||||
// presented with. It replaces the globe this file used to draw in SVG.
|
||||
//
|
||||
// It starts where the news panel ends, so the wordmark is never half-hidden
|
||||
// behind it and the panel's side of the window stays plain black.
|
||||
import keyart from "../assets/keyart.webp";
|
||||
</script>
|
||||
|
||||
<div class="keyart" aria-hidden="true">
|
||||
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="ka-glow" cx={CX} cy={CY} r={R * 1.9} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#5b8cff" stop-opacity="0.2" />
|
||||
<stop offset="0.42" stop-color="#5b8cff" stop-opacity="0.06" />
|
||||
<stop offset="1" stop-color="#5b8cff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="ka-sphere" cx={CX - R * 0.38} cy={CY - R * 0.42} r={R * 1.55} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#172241" />
|
||||
<stop offset="0.55" stop-color="#0d1019" />
|
||||
<stop offset="1" stop-color="#09090b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="ka-fade" cx="0.72" cy="0.5" r="0.62">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="1" />
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<pattern id="ka-dots" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<rect width="1" height="1" fill="#4a4a52" />
|
||||
</pattern>
|
||||
<mask id="ka-dots-mask">
|
||||
<rect width={W} height={H} fill="url(#ka-fade)" />
|
||||
</mask>
|
||||
<mask id="ka-behind" maskUnits="userSpaceOnUse" x="-600" y="-600" width={W + 1200} height={H + 1200}>
|
||||
<rect x="-600" y="-600" width={W + 1200} height={H + 1200} fill="#fff" />
|
||||
<circle cx={CX} cy={CY} r={R} fill="#000" />
|
||||
</mask>
|
||||
<path id="ka-orbit" d={orbitPath} />
|
||||
</defs>
|
||||
|
||||
<rect width={W} height={H} fill="#09090b" />
|
||||
<rect width={W} height={H} fill="url(#ka-dots)" mask="url(#ka-dots-mask)" />
|
||||
|
||||
<g fill="none" stroke="#9a999e" stroke-opacity="0.075">
|
||||
{#each topo as d, i (i)}
|
||||
<path {d} />
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<circle cx={CX} cy={CY} r={R * 1.9} fill="url(#ka-glow)" />
|
||||
|
||||
<g stroke="#9a999e" fill="none">
|
||||
<circle cx={CX} cy={CY} r={RING} stroke-opacity="0.12" />
|
||||
<path d={ticks} stroke-opacity="0.22" />
|
||||
</g>
|
||||
<g class="ring-labels" fill="#9a999e" fill-opacity="0.34">
|
||||
{#each ringLabels as l (l.text)}
|
||||
<text x={fmt(l.x)} y={fmt(l.y)} text-anchor="middle" dominant-baseline="central">{l.text}</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
|
||||
<path d={orbitBack} stroke-opacity="0.22" stroke-dasharray="2 6" mask="url(#ka-behind)" />
|
||||
</g>
|
||||
|
||||
<circle cx={CX} cy={CY} r={R} fill="url(#ka-sphere)" />
|
||||
<path d={gridBack} fill="none" stroke="#5b8cff" stroke-opacity="0.06" />
|
||||
<path d={gridFront} fill="none" stroke="#7aa2ff" stroke-opacity="0.2" stroke-width="0.9" />
|
||||
<path d={equator} fill="none" stroke="#7aa2ff" stroke-opacity="0.38" />
|
||||
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#5b8cff" stroke-opacity="0.55" />
|
||||
<circle cx={CX} cy={CY} r={R + 5} fill="none" stroke="#5b8cff" stroke-opacity="0.12" />
|
||||
|
||||
<path d={route} fill="none" stroke="#5b8cff" stroke-opacity="0.75" stroke-dasharray="5 4" />
|
||||
<g class="markers">
|
||||
{#each markers as m (m.label)}
|
||||
<g transform="translate({fmt(m.x)} {fmt(m.y)})">
|
||||
<rect x="-4.5" y="-4.5" width="9" height="9" fill="#09090b" stroke="#a9c1ff" stroke-opacity="0.9" />
|
||||
<rect x="-1" y="-1" width="2" height="2" fill="#d6e2ff" />
|
||||
<path d="M5 -5L16 -16H64" fill="none" stroke="#9a999e" stroke-opacity="0.4" />
|
||||
<text x="20" y="-21" fill="#d4d3d0" fill-opacity="0.62">{m.label}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
|
||||
<path d={orbitFront} stroke-opacity="0.34" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Separate layer so the static art above never repaints. -->
|
||||
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
|
||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})">
|
||||
{#if prefersReducedMotion.current}
|
||||
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="7" fill="#5b8cff" fill-opacity="0.18" />
|
||||
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="2.5" fill="#d6e2ff" />
|
||||
{:else}
|
||||
<g mask="url(#ka-behind)">
|
||||
<g>
|
||||
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
|
||||
<circle r="2.5" fill="#d6e2ff" />
|
||||
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
|
||||
<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
|
||||
</g>
|
||||
</g>
|
||||
<g opacity="0">
|
||||
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
|
||||
<circle r="2.5" fill="#d6e2ff" />
|
||||
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
|
||||
<animate attributeName="opacity" values="0;1" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
|
||||
</g>
|
||||
{/if}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="frame">
|
||||
<img class="art" src={keyart} alt="" draggable="false" />
|
||||
</div>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
@@ -260,27 +24,48 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer {
|
||||
/* `left` is where the hub's first column ends: its padding plus the column's
|
||||
width, kept in step with App.svelte's grid (and its 1140px breakpoint). The
|
||||
frame is what crops the picture - an <img> left to itself takes its own
|
||||
aspect ratio, whatever the box says, and `object-position` then has nothing
|
||||
to slide. The left edge fades in, so the black beside it is a backdrop
|
||||
rather than a seam. */
|
||||
.frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 432px;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
|
||||
}
|
||||
|
||||
/* Cover, then slide: this picks the whole wordmark and the near half of the
|
||||
globe, with the mark starting just clear of the panel. */
|
||||
.art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ring-labels,
|
||||
.markers {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Only the edges, where the title bar and the status bar sit: the wordmark and
|
||||
the globe are the point, and a left-hand wash would swallow them. */
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0.5) 30%, rgba(9, 9, 11, 0) 52%),
|
||||
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 32%),
|
||||
linear-gradient(180deg, rgba(9, 9, 11, 0.55) 0%, rgba(9, 9, 11, 0) 18%),
|
||||
radial-gradient(130% 100% at 72% 50%, transparent 55%, rgba(9, 9, 11, 0.65) 100%);
|
||||
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 28%),
|
||||
linear-gradient(180deg, rgba(9, 9, 11, 0.6) 0%, rgba(9, 9, 11, 0) 16%),
|
||||
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 1140px) {
|
||||
.frame {
|
||||
left: 384px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,10 +105,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
function presenceLabel(item: MarketItem): string {
|
||||
if (item.presence === "installed") return "Installed";
|
||||
if (item.presence === "partial") return "Partly downloaded";
|
||||
return "Not installed";
|
||||
// "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 {
|
||||
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."
|
||||
: "Not on this PC, and your DHP is out of reach.";
|
||||
}
|
||||
|
||||
function changeNote(item: MarketItem): string | null {
|
||||
@@ -246,7 +259,9 @@
|
||||
</span>
|
||||
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="pill {item.presence}">{presenceLabel(item)}</span>
|
||||
<span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
|
||||
{presenceLabel(item)}
|
||||
</span>
|
||||
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
@@ -532,6 +547,11 @@
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
/* Drawn by the DHP: a working state, not a warning. */
|
||||
.pill.from-site {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.change {
|
||||
color: var(--accent-hover);
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" };
|
||||
const EMPTY: Settings = { site_url: "", mcp_url: "", ai_url: "", install_dir: "" };
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let draft = $state<Settings>({ ...EMPTY });
|
||||
@@ -31,20 +31,26 @@
|
||||
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(),
|
||||
});
|
||||
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),
|
||||
);
|
||||
/** 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));
|
||||
/** 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 || dirError !== null || offDrive);
|
||||
const invalid = $derived(
|
||||
siteError !== null || mcpError !== null || aiError !== null || dirError !== null || offDrive,
|
||||
);
|
||||
|
||||
function urlProblem(value: string): string | null {
|
||||
if (!value) return "Required.";
|
||||
@@ -199,6 +205,25 @@
|
||||
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
|
||||
</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}>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMount } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
// ODIAN's own mark, the same one the window and the taskbar carry.
|
||||
import mark from "../assets/mark.png";
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
@@ -44,11 +46,7 @@
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
<div class="brand" data-tauri-drag-region>
|
||||
<svg class="mark" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" data-tauri-drag-region>
|
||||
<rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="currentColor" stroke-opacity="0.45" />
|
||||
<path d="M9 3.5L14.5 9 9 14.5 3.5 9z" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="8" y="8" width="2" height="2" fill="currentColor" />
|
||||
</svg>
|
||||
<img class="mark" src={mark} width="18" height="18" alt="" draggable="false" data-tauri-drag-region />
|
||||
<span class="wordmark" data-tauri-drag-region>ODIAN</span>
|
||||
<span class="product" data-tauri-drag-region>Launcher</span>
|
||||
</div>
|
||||
@@ -130,7 +128,9 @@
|
||||
}
|
||||
|
||||
.mark {
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
|
||||
Reference in New Issue
Block a user