This commit is contained in:
Kelorastus
2026-09-16 11:42:24 +02:00
parent b7f3061be3
commit 3fdfde7043
18 changed files with 1485 additions and 21 deletions
+35
View File
@@ -60,6 +60,8 @@
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
case "restoring":
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "syncing":
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
@@ -165,6 +167,21 @@
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if}
</p>
{#if launcher.syncAttention > 0}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
<strong>{launcher.syncAttention}</strong>
{launcher.syncAttention === 1 ? "change needs" : "changes need"} your decision.
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Review</button>
</span>
</p>
{:else if s?.online || s?.sync}
<p class="secondary small">
Operator data syncs with {site} before every launch.
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{:else if action === "installing"}
<div class="progress">
<div class="stage-line">
@@ -211,6 +228,14 @@
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
</div>
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
{:else if action === "syncing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Exchanging operator data</span>
</div>
<ProgressBar value={null} label="Syncing with the DHP" />
</div>
<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"} />
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
@@ -371,6 +396,16 @@
.secondary.small {
font-size: var(--text-xs);
}
.attention {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--ink-2);
}
.attention :global(.icon) {
margin-top: 3px;
color: var(--warn);
}
.secondary strong {
color: var(--ink-2);
font-weight: 500;
+37
View File
@@ -39,6 +39,21 @@
<span>Installed</span>
<strong class="mono">{s.installed ?? "none"}</strong>
</span>
{#if s.live !== null}
<span class="item" title={s.live ? `ODIAN is reading and writing ${launcher.siteName}'s data` : "ODIAN is working from this PC's data"}>
<Icon name={s.live ? "globe" : "folder"} size={14} />
<span>{s.live ? `Live on ${launcher.siteName}` : "This PC's data"}</span>
</span>
{/if}
{#if s.sync}
<button class="item sync-item" aria-controls="sync-panel" onclick={() => launcher.openSync()}>
<Icon name="refresh" size={14} />
<span><strong class="mono">{s.sync.pending}</strong> to send</span>
{#if s.sync.conflicts + s.sync.rejected > 0}
<strong class="review mono">{s.sync.conflicts + s.sync.rejected} to review</strong>
{/if}
</button>
{/if}
<span class="item dir" title={s.install_dir}>
<Icon name="folder" size={14} />
<span class="mono selectable path">{s.install_dir}</span>
@@ -122,6 +137,28 @@
font-weight: 500;
}
.sync-item {
border-top: 0;
border-bottom: 0;
border-left: 0;
background: transparent;
color: inherit;
font: inherit;
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.sync-item:hover {
background: var(--surface-2);
color: var(--ink);
}
.sync-item:focus-visible {
outline-offset: -2px;
}
.item strong.review {
color: var(--warn);
}
.dir {
flex-shrink: 1;
}
+316
View File
@@ -0,0 +1,316 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { prefersReducedMotion } from "svelte/motion";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import type { ConflictChoice, SyncConflict } from "../lib/types";
const KIND: Record<string, string> = {
db_ground_poi: "POI",
db_ground_target: "Target",
db_ground_area: "Area",
db_ground_line: "Line",
odb_note: "Note",
odb_folder: "Folder",
odb_images: "Image",
odb_sitac_operation: "Operation",
odb_sitac_operation_snapshot: "Operation state",
odb_target_list: "Target list",
odb_user_prefs: "Preferences",
odb_user_layouts: "Layouts",
};
const s = $derived(launcher.status);
const disabled = $derived(launcher.busy !== null || launcher.locked);
type Row = Record<string, unknown>;
const asRow = (value: unknown): Row | null =>
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Row) : null;
function kind(c: SyncConflict): string {
return KIND[c.tbl] ?? c.tbl.replace(/^o?db_/, "").replaceAll("_", " ");
}
function title(c: SyncConflict): string {
for (const side of [c.mine, c.theirs, c.base]) {
const row = asRow(side);
const name = row?.name ?? row?.title;
if (typeof name === "string" && name) return name;
}
return Object.values(c.key).join(" · ");
}
function shown(value: unknown): string {
if (value === undefined || value === null) return "—";
return typeof value === "string" ? value : JSON.stringify(value);
}
function rows(c: SyncConflict) {
return c.fields
.filter((field) => !field.startsWith("*"))
.map((field) => ({ field, mine: shown(asRow(c.mine)?.[field]), theirs: shown(asRow(c.theirs)?.[field]) }));
}
function explain(c: SyncConflict): string | null {
if (c.kind === "rejected") return `The DHP refused this change: ${c.reason ?? "no reason given"}.`;
if (c.fields.includes("*exists")) {
return c.mine === null
? "You deleted this on this PC; someone changed it on the DHP."
: "You changed this on this PC; someone deleted it on the DHP.";
}
if (c.fields.includes("*value")) return "It changed differently on this PC and on the DHP.";
return "The same fields changed on this PC and on the DHP.";
}
function choices(c: SyncConflict): [ConflictChoice, string][] {
if (c.kind === "rejected") {
return [
["theirs", "Undo my change"],
["dismiss", "Keep it on this PC only"],
];
}
if (c.fields.includes("*exists")) {
return c.mine === null
? [
["mine", "Delete it everywhere"],
["theirs", "Keep the DHP's version"],
]
: [
["mine", "Keep my version"],
["theirs", "Accept the delete"],
];
}
return [
["mine", "Keep this PC's"],
["theirs", "Take the DHP's"],
];
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector("dialog[open]")) launcher.closeSync();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<section
id="sync-panel"
class="sync glass"
aria-label="Sync review"
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
>
<header class="head">
<h2 class="eyebrow">Sync</h2>
{#if launcher.conflicts?.length}<span class="total mono">{launcher.conflicts.length}</span>{/if}
<span class="grow"></span>
<button class="btn small" disabled={disabled || !s?.online} onclick={() => launcher.syncNow()}>
{launcher.busy === "sync" ? "Syncing…" : "Sync now"}
</button>
<button class="icon-btn" aria-label="Close sync review" onclick={() => launcher.closeSync()}>
<Icon name="close" size={14} />
</button>
</header>
{#if s?.sync}
<p class="summary">
<span><strong class="mono">{s.sync.pending}</strong> waiting to send</span>
{#if s.sync.waiting > 0}<span><strong class="mono">{s.sync.waiting}</strong> DHP changes not applied yet</span>{/if}
</p>
{/if}
{#if launcher.locked}
<p class="empty">Decisions can be made once ODIAN has stopped.</p>
{:else if launcher.conflicts === null}
<p class="empty">{launcher.busy === "conflicts" ? "Reading this PC's database…" : "Nothing loaded yet."}</p>
{:else if launcher.conflicts.length === 0}
<p class="empty">Nothing needs your decision. Operator data syncs before every launch and after ODIAN closes.</p>
{:else}
<ol class="list" aria-label="Changes that need a decision">
{#each launcher.conflicts as c (c.tbl + JSON.stringify(c.key))}
<li class="item">
<div class="item-head">
<span class="kind mono">{kind(c)}</span>
<span class="name selectable" title={title(c)}>{title(c)}</span>
<span class="tag" class:refused={c.kind === "rejected"}>{c.kind === "rejected" ? "Refused" : "Conflict"}</span>
</div>
<p class="why">{explain(c)}</p>
{#if rows(c).length}
<div class="diff-wrap">
<table class="diff">
<thead>
<tr><th scope="col">Field</th><th scope="col">This PC</th><th scope="col">DHP</th></tr>
</thead>
<tbody>
{#each rows(c) as row (row.field)}
<tr>
<th scope="row" class="mono">{row.field}</th>
<td class="selectable">{row.mine}</td>
<td class="selectable">{row.theirs}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="actions">
{#each choices(c) as [choice, label] (choice)}
<button class="btn small" {disabled} onclick={() => launcher.resolveConflict(c, choice)}>{label}</button>
{/each}
</div>
</li>
{/each}
</ol>
{/if}
</section>
<style>
.sync {
display: flex;
flex-direction: column;
max-height: min(520px, calc(100vh - 180px));
background: var(--glass-strong);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
}
.total {
color: var(--muted);
font-size: var(--text-xs);
}
.grow {
flex: 1;
}
.small {
height: 28px;
padding: 0 10px;
font-size: var(--text-xs);
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: var(--text-xs);
}
.summary strong {
color: var(--ink-2);
font-weight: 500;
}
.empty {
padding: 16px;
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.5;
}
.list {
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.item {
display: grid;
gap: 8px;
padding: 12px 16px;
}
.item + .item {
border-top: 1px solid var(--line);
}
.item-head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.kind {
color: var(--muted);
font-size: var(--text-xs);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--ink);
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.tag {
padding: 1px 6px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--accent-hover);
font-size: var(--text-xs);
}
.tag.refused {
background: var(--danger-soft);
color: var(--danger);
}
.why {
color: var(--ink-2);
font-size: var(--text-xs);
line-height: 1.5;
}
.diff-wrap {
overflow-x: auto;
border: 1px solid var(--line);
}
.diff {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
.diff th,
.diff td {
padding: 5px 8px;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.diff thead th {
background: var(--surface-2);
color: var(--muted);
font-weight: 500;
}
.diff tbody tr + tr {
border-top: 1px solid var(--line);
}
.diff tbody th {
color: var(--muted);
font-weight: 400;
}
.diff td {
color: var(--ink-2);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>