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>
This commit is contained in:
@@ -62,5 +62,5 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Desktop onOpenConnection={() => (configured = false)} />
|
<Desktop />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import Ops from '../pages/Ops.svelte'
|
|||||||
import Signals from '../pages/Signals.svelte'
|
import Signals from '../pages/Signals.svelte'
|
||||||
import Knowledge from '../pages/Knowledge.svelte'
|
import Knowledge from '../pages/Knowledge.svelte'
|
||||||
import Learning from '../pages/Learning.svelte'
|
import Learning from '../pages/Learning.svelte'
|
||||||
|
import Settings from '../pages/Settings.svelte'
|
||||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||||
import SearchIcon from '@lucide/svelte/icons/search'
|
import SearchIcon from '@lucide/svelte/icons/search'
|
||||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||||
|
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||||
|
|
||||||
export interface AppDef {
|
export interface AppDef {
|
||||||
id: string
|
id: string
|
||||||
@@ -99,6 +101,16 @@ export const APPS: AppDef[] = [
|
|||||||
height: 600,
|
height: 600,
|
||||||
minWidth: 440,
|
minWidth: 440,
|
||||||
minHeight: 340
|
minHeight: 340
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
title: 'Settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
component: Settings,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
minWidth: 480,
|
||||||
|
minHeight: 360
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@
|
|||||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||||
|
|
||||||
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
|
|
||||||
|
|
||||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
||||||
// window — the familiar "click empty desktop to deselect" affordance.
|
// window — the familiar "click empty desktop to deselect" affordance.
|
||||||
function onSurfaceClick(e: MouseEvent) {
|
function onSurfaceClick(e: MouseEvent) {
|
||||||
@@ -104,7 +102,7 @@
|
|||||||
<WindowLayer />
|
<WindowLayer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Taskbar {onOpenConnection} />
|
<Taskbar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if menuPos}
|
{#if menuPos}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// to the old MinimizedWindowsBar, which only ever showed minimized windows
|
// to the old MinimizedWindowsBar, which only ever showed minimized windows
|
||||||
// and gave no way to see/switch between windows that were merely
|
// and gave no way to see/switch between windows that were merely
|
||||||
// unfocused), plus a system tray for theme/connection/version.
|
// unfocused), plus a system tray for theme/connection/version.
|
||||||
import { wm, wmState, toggleShowDesktop } from '$lib/stores/windows'
|
import { wm, wmState, toggleShowDesktop, openAppWindow } from '$lib/stores/windows'
|
||||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||||
import { summary } from '$lib/stores/context'
|
import { summary } from '$lib/stores/context'
|
||||||
import { truncateMiddle } from '$lib/utils'
|
import { truncateMiddle } from '$lib/utils'
|
||||||
@@ -19,8 +19,6 @@
|
|||||||
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
|
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
|
|
||||||
|
|
||||||
const SESSION_PREFIX = 'session:'
|
const SESSION_PREFIX = 'session:'
|
||||||
|
|
||||||
const buttons = $derived(
|
const buttons = $derived(
|
||||||
@@ -121,8 +119,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
onclick={onOpenConnection}
|
onclick={() => openAppWindow('settings')}
|
||||||
title="Server connection settings"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon class="size-4" />
|
<SettingsIcon class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
193
web/src/pages/Settings.svelte
Normal file
193
web/src/pages/Settings.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The desktop's Settings window — a generic shell (section list + content
|
||||||
|
// pane) so future settings just add a SECTIONS entry instead of a whole
|
||||||
|
// new window/screen. Connection reuses the same config.ts/oidc.ts calls as
|
||||||
|
// pages/Config.svelte (the full-page initial setup screen), just without
|
||||||
|
// that screen's "first run" framing — this is for changing settings while
|
||||||
|
// already inside the desktop.
|
||||||
|
import { Input } from '$lib/components/ui/input'
|
||||||
|
import { Label } from '$lib/components/ui/label'
|
||||||
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import { Separator } from '$lib/components/ui/separator'
|
||||||
|
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig, type OikosConfig } from '$lib/config'
|
||||||
|
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||||
|
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
|
||||||
|
import { VERSION } from '$lib/version'
|
||||||
|
import { toast } from 'svelte-sonner'
|
||||||
|
import PlugIcon from '@lucide/svelte/icons/plug'
|
||||||
|
import PaletteIcon from '@lucide/svelte/icons/palette'
|
||||||
|
import LockIcon from '@lucide/svelte/icons/lock'
|
||||||
|
import LogInIcon from '@lucide/svelte/icons/log-in'
|
||||||
|
import ServerIcon from '@lucide/svelte/icons/server'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'connection', label: 'Connection', icon: PlugIcon },
|
||||||
|
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon }
|
||||||
|
] as const
|
||||||
|
type SectionId = (typeof SECTIONS)[number]['id']
|
||||||
|
|
||||||
|
let section = $state<SectionId>('connection')
|
||||||
|
|
||||||
|
const existing = getConfig()
|
||||||
|
let apiUrl = $state(existing.apiUrl ?? '')
|
||||||
|
let token = $state(existing.token ?? '')
|
||||||
|
let saving = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
let oidcLoggingIn = $state(false)
|
||||||
|
const oidcUser = getUser()
|
||||||
|
const oidcConfigured = isOIDCConfigured()
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
error = ''
|
||||||
|
if (!token.trim()) {
|
||||||
|
error = 'Token is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving = true
|
||||||
|
const prev: OikosConfig = getConfig()
|
||||||
|
setConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
|
||||||
|
initConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth('/api/v1/dashboard/summary')
|
||||||
|
if (!res.ok) {
|
||||||
|
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
|
||||||
|
setConfig(prev)
|
||||||
|
initConfig(prev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success('Connection saved')
|
||||||
|
} catch {
|
||||||
|
error = 'Could not reach server — check the URL'
|
||||||
|
setConfig(prev)
|
||||||
|
initConfig(prev)
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithAuthentik() {
|
||||||
|
error = ''
|
||||||
|
oidcLoggingIn = true
|
||||||
|
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||||
|
initConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||||
|
try {
|
||||||
|
await startLogin()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : 'OIDC login failed'
|
||||||
|
oidcLoggingIn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnecting invalidates the token every open window's data depends on
|
||||||
|
// — a reload is the simplest way back to a clean state (lands on the
|
||||||
|
// full-page Config screen since isConfigured() is now false).
|
||||||
|
function forgetConnection() {
|
||||||
|
clearConfig()
|
||||||
|
oidcLogout()
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTheme(t: Theme) {
|
||||||
|
setTheme(t)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r bg-muted/20 p-2">
|
||||||
|
{#each SECTIONS as s (s.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors {section === s.id
|
||||||
|
? 'bg-muted font-medium text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
|
||||||
|
onclick={() => (section = s.id)}
|
||||||
|
>
|
||||||
|
<s.icon class="size-4 shrink-0" />
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
{#if section === 'connection'}
|
||||||
|
<div class="mx-auto flex max-w-md flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold">Connection</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">Where the control room talks to your Oikos server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if oidcUser}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Signed in via Authentik as <span class="font-medium text-foreground">{oidcUser}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Label for="settings-server-url" class="text-xs font-medium">Server URL</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<ServerIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||||
|
<Input id="settings-server-url" type="url" placeholder="https://oikos.hubris.network" class="pl-9" bind:value={apiUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" variant="secondary" disabled={oidcLoggingIn} onclick={loginWithAuthentik} class="w-full gap-2">
|
||||||
|
<LogInIcon class="size-4" />
|
||||||
|
{oidcLoggingIn ? 'Redirecting…' : oidcConfigured ? 'Switch account with Authentik' : 'Login with Authentik'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Separator decorative class="flex-1" />
|
||||||
|
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">or use</span>
|
||||||
|
<Separator decorative class="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-2.5" onsubmit={(e) => { e.preventDefault(); save() }}>
|
||||||
|
<div class="relative">
|
||||||
|
<LockIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||||
|
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={saving} class="w-full">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if existing.token || oidcConfigured}
|
||||||
|
<div class="flex justify-center pt-1">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onclick={forgetConnection}>Forget saved connection</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if section === 'appearance'}
|
||||||
|
<div class="mx-auto flex max-w-md flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold">Appearance</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">Pick the theme for the whole desktop.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-between rounded-lg border px-3.5 py-2.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
|
||||||
|
? 'border-primary/50 bg-primary/5 text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50'}"
|
||||||
|
onclick={() => pickTheme(id as Theme)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if getTheme() === (id as Theme)}<CheckIcon class="size-4 text-primary" />{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0 border-t px-3 py-1.5 text-right text-[11px] text-muted-foreground">Oikos {VERSION}</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user