Files
oikos/web/src/lib/components/desktop-shell/Taskbar.svelte
dtoro aee458ce83
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): add windowed Settings app, separate from initial Config screen
The taskbar's gear icon reopened the full-page "Connect to Oikos" screen
even once already connected. Split that: Config.svelte stays as the
first-run/unconfigured screen; a new Settings app (windowed, like Tasks or
Operations) now handles in-session changes, with a section list (Connection,
Appearance) built to grow — future settings are one more entry, not a new
screen.

- pages/Settings.svelte: Connection (server URL/token/Authentik, reusing
  config.ts + oidc.ts) and Appearance (Terracotta/Carbon picker) sections.
- apps.ts: registered as a normal desktop app.
- Taskbar's gear button now opens the Settings window; removed the
  onOpenConnection prop threaded through App -> Desktop -> Taskbar, since
  Settings' "Forget saved connection" (clear config + reload) replaces it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:00:57 +02:00

130 lines
5.0 KiB
Svelte

<script lang="ts">
// Bottom taskbar: a real flex row in the page layout (not an overlay), so
// windows can never be dragged/maximized underneath it — see Desktop.svelte
// for how the window layer's bounds are scoped to the surface above this.
// Shows a button for every open window (not just minimized ones — compare
// to the old MinimizedWindowsBar, which only ever showed minimized windows
// and gave no way to see/switch between windows that were merely
// unfocused), plus a system tray for theme/connection/version.
import { wm, wmState, toggleShowDesktop, openAppWindow } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import { summary } from '$lib/stores/context'
import { truncateMiddle } from '$lib/utils'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
import { VERSION } from '$lib/version'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PaletteIcon from '@lucide/svelte/icons/palette'
import SettingsIcon from '@lucide/svelte/icons/settings'
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
import XIcon from '@lucide/svelte/icons/x'
const SESSION_PREFIX = 'session:'
const buttons = $derived(
[...$wmState.order]
.map((id) => $wmState.windows[id])
.filter((w): w is NonNullable<typeof w> => !!w)
.sort((a, b) => a.openedSeq - b.openedSeq)
)
function iconFor(id: string) {
const appId = appIdFromWindowId(id)
if (appId) return appById.get(appId)?.icon
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
return DatabaseIcon
}
function badgeFor(id: string): number {
const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0
}
function toggle(id: string, win: (typeof buttons)[number]) {
if (win.stage === 'minimized') {
wm.restore(id)
wm.focus(id)
} else if ($wmState.focusedId === id) {
wm.minimize(id)
} else {
wm.focus(id)
}
}
</script>
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
<button
type="button"
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={toggleShowDesktop}
title="Show desktop"
>
<LayoutGridIcon class="size-4" />
</button>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{#each buttons as win (win.id)}
{@const Icon = iconFor(win.id)}
{@const badge = badgeFor(win.id)}
<div class="group/tb relative flex shrink-0">
<button
type="button"
data-taskbar-btn={win.id}
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
win.id && win.stage !== 'minimized'
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
onclick={() => toggle(win.id, win)}
title={win.title}
>
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
{#if badge > 0}
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
{badge > 99 ? '99+' : badge}
</span>
{/if}
</button>
<button
type="button"
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
onclick={(e) => {
e.stopPropagation()
wm.close(win.id)
}}
aria-label="Close {win.title}"
>
<XIcon class="size-2.5" />
</button>
</div>
{/each}
</div>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => toggleTheme()}
title="Cycle theme"
>
<PaletteIcon class="size-4" />
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
</button>
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => openAppWindow('settings')}
title="Settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>