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

302 lines
7.8 KiB
Svelte

<script lang="ts">
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;
onOpenMarket: () => void;
}
let { onOpenSettings, onOpenMarket }: Props = $props();
let maximized = $state(false);
const status = $derived(launcher.status);
const checking = $derived(launcher.refreshing || status?.phase === "checking");
// The licence, not a setting: an operator's launcher must show no trace of dev mode.
const licensed = $derived((status?.dev ?? null) !== null);
const devOn = $derived(launcher.devOn);
const saving = $derived(launcher.busy === "settings");
function windowAction(task: () => Promise<void>) {
return () => {
task().catch((e) => console.error("window control failed", e));
};
}
onMount(() => {
let disposed = false;
let unlisten: (() => void) | undefined;
const sync = () => {
launcher.win
.isMaximized()
.then((value) => (maximized = value))
.catch(() => {});
};
sync();
launcher.win
.onResized(sync)
.then((u) => (disposed ? u() : (unlisten = u)))
.catch(() => {});
return () => {
disposed = true;
unlisten?.();
};
});
</script>
<header class="titlebar" data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
<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>
{#if status}
<span class="divider" aria-hidden="true" data-tauri-drag-region></span>
<div class="site" data-tauri-drag-region>
<span class="site-name" title={status.site.url} data-tauri-drag-region>{launcher.siteName}</span>
<span
class="badge"
class:online={status.online}
class:checking
role="status"
aria-busy={checking}
data-tauri-drag-region
>
<span class="badge-dot" aria-hidden="true"></span>
{status.online ? "Online" : "Offline"}
</span>
<button
class="icon-btn refresh"
aria-label="Check the connection to your DHP"
title="Check connection"
disabled={checking}
onclick={() => launcher.refresh()}
>
<span class="refresh-icon" class:spinning={checking}><Icon name="refresh" size={14} /></span>
</button>
</div>
{/if}
<div class="drag" data-tauri-drag-region></div>
{#if licensed}
<!-- Only on a PC that holds a dev licence. An operator's launcher never draws it, which
is the whole point of the licence - so this sits behind `status.dev`, not a setting. -->
<button
class="icon-btn dev"
class:on={devOn}
aria-label={devOn ? "Dev mode is on" : "Turn dev mode on"}
title={devOn ? "Dev mode is ON - ODIAN runs from your checkout" : "Dev mode"}
aria-pressed={devOn}
disabled={saving}
onclick={() => launcher.setDev({ dev_mode: !devOn })}
>
<Icon name="code" />
</button>
{/if}
<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>
<div class="window-controls">
<button class="win" aria-label="Minimize" title="Minimize" onclick={windowAction(() => launcher.win.minimize())}>
<Icon name="minimize" size={14} strokeWidth={1.25} />
</button>
<button
class="win"
aria-label={maximized ? "Restore" : "Maximize"}
title={maximized ? "Restore" : "Maximize"}
onclick={windowAction(() => launcher.win.toggleMaximize())}
>
<Icon name={maximized ? "restore" : "maximize"} size={14} strokeWidth={1.25} />
</button>
<button class="win close" aria-label="Close" title="Close" onclick={windowAction(() => launcher.win.close())}>
<Icon name="close" size={14} strokeWidth={1.25} />
</button>
</div>
</header>
<style>
/* The dev switch. Off it is one more grey icon button; on it is unmistakably GREEN,
because "am I running my own build or the installed one" is the question this whole
mode exists to answer, and guessing it wrong wastes an afternoon. */
.icon-btn.dev.on {
color: #46d17f;
border-color: color-mix(in srgb, #46d17f 55%, transparent);
background: color-mix(in srgb, #46d17f 16%, transparent);
}
.icon-btn.dev.on:hover:not(:disabled) {
background: color-mix(in srgb, #46d17f 26%, transparent);
}
.titlebar {
position: relative;
z-index: 20;
display: flex;
align-items: center;
height: var(--titlebar-h);
padding-left: var(--space-4);
border-bottom: 1px solid rgba(35, 35, 38, 0.8);
background: linear-gradient(180deg, rgba(9, 9, 11, 0.9), rgba(9, 9, 11, 0.6));
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
}
.mark {
display: block;
border-radius: 4px;
user-select: none;
}
.wordmark {
margin-right: calc(var(--wordmark-tracking) * -1);
color: var(--ink);
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
letter-spacing: var(--wordmark-tracking);
}
.product {
margin-left: 6px;
color: var(--muted);
font-size: var(--text-xs);
letter-spacing: 0.04em;
}
.divider {
width: 1px;
height: 16px;
margin: 0 var(--space-4);
background: var(--line-strong);
}
.site {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
height: 100%;
font-size: var(--text-sm);
}
.site-name {
max-width: 280px;
overflow: hidden;
color: var(--ink-2);
white-space: nowrap;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 8px;
border: 1px solid rgba(245, 180, 84, 0.3);
border-radius: var(--radius);
background: var(--warn-soft);
color: var(--warn);
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
transition:
color var(--dur) var(--ease),
background-color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.badge.online {
border-color: rgba(134, 239, 172, 0.28);
background: var(--ok-soft);
color: var(--ok);
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge.checking .badge-dot {
animation: pulse 1.1s var(--ease) infinite;
}
.refresh {
width: 28px;
height: 28px;
}
.refresh:disabled {
opacity: 1;
}
.refresh-icon {
display: grid;
}
.spinning {
animation: spin 0.9s linear infinite;
}
.drag {
flex: 1;
align-self: stretch;
}
.market {
margin-right: var(--space-1);
}
.settings {
margin-right: var(--space-2);
}
.window-controls {
display: flex;
align-self: stretch;
border-left: 1px solid var(--line);
}
.win {
display: grid;
place-items: center;
width: 46px;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.win:hover {
background: var(--surface-2);
color: var(--ink);
}
.win.close:hover {
background: var(--danger);
color: var(--accent-ink);
}
.win:focus-visible {
outline-offset: -2px;
}
</style>