feat(web): redesign config screen with animated particle background and unified auth layout
This commit is contained in:
171
web/src/lib/components/ConfigBackground.svelte
Normal file
171
web/src/lib/components/ConfigBackground.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { mode } from 'mode-watcher'
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
r: number
|
||||
phase: number
|
||||
pulse: number
|
||||
}
|
||||
|
||||
const COUNT = 90
|
||||
const CONNECT_DIST = 160
|
||||
const MOUSE_RADIUS = 200
|
||||
const MOUSE_FORCE = 0.012
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
let particles: Particle[] = []
|
||||
let mouse = { x: -500, y: -500 }
|
||||
let w = 0, h = 0, dpr = 1
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
|
||||
function spawn() {
|
||||
particles = Array.from({ length: COUNT }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
r: 1 + Math.random() * 2,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
pulse: 0.4 + Math.random() * 0.6
|
||||
}))
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!host || !canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
w = host.clientWidth
|
||||
h = host.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
if (particles.length === 0) spawn()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
mouse.x = e.clientX - rect.left
|
||||
mouse.y = e.clientY - rect.top
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
mouse.x = -500
|
||||
mouse.y = -500
|
||||
}
|
||||
|
||||
function draw(ts: number) {
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
if (!canvas || particles.length === 0) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const t = ts / 1000
|
||||
const dark = mode.current !== 'light'
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// update + draw particles
|
||||
for (const p of particles) {
|
||||
// autonomous drift
|
||||
p.vx += (Math.sin(t * 0.4 + p.phase) * 0.003) * 0.15
|
||||
p.vy += (Math.cos(t * 0.35 + p.phase) * 0.003) * 0.15
|
||||
|
||||
// mouse interaction
|
||||
const dx = p.x - mouse.x
|
||||
const dy = p.y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < MOUSE_RADIUS && dist > 0) {
|
||||
const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS * MOUSE_FORCE
|
||||
p.vx += (dx / dist) * force * 0.6
|
||||
p.vy += (dy / dist) * force * 0.6
|
||||
}
|
||||
|
||||
// friction + random nudge
|
||||
p.vx *= 0.995
|
||||
p.vy *= 0.995
|
||||
if (Math.random() < 0.003) {
|
||||
p.vx += (Math.random() - 0.5) * 0.04
|
||||
p.vy += (Math.random() - 0.5) * 0.04
|
||||
}
|
||||
|
||||
// wrap
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
if (p.x < -40) p.x = w + 40
|
||||
if (p.x > w + 40) p.x = -40
|
||||
if (p.y < -40) p.y = h + 40
|
||||
if (p.y > h + 40) p.y = -40
|
||||
|
||||
// pulse brightness
|
||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||
ctx.fillStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// connections between nearby particles
|
||||
ctx.lineWidth = 0.6
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const a = particles[i]
|
||||
const b = particles[j]
|
||||
const dx = a.x - b.x
|
||||
const dy = a.y - b.y
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||
ctx.strokeStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// radial scrim to keep center legible
|
||||
const cx = w / 2, cy = h / 2
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
||||
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
|
||||
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
|
||||
scrim.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = scrim
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resize()
|
||||
spawn()
|
||||
const ro = new ResizeObserver(() => {
|
||||
resize()
|
||||
spawn()
|
||||
})
|
||||
if (host) ro.observe(host)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerleave', onPointerLeave)
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerleave', onPointerLeave)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="absolute inset-0 overflow-hidden bg-background">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import * as Tabs from '$lib/components/ui/tabs'
|
||||
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 { Lock, LogIn, Server } from '@lucide/svelte'
|
||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
|
||||
|
||||
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
}
|
||||
saveToDesktop()
|
||||
onConnected()
|
||||
} catch (e) {
|
||||
} catch {
|
||||
error = 'Could not reach server — check the URL'
|
||||
} finally {
|
||||
connecting = false
|
||||
@@ -58,7 +59,7 @@
|
||||
try {
|
||||
wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim())
|
||||
} catch {
|
||||
// ignore — optional desktop-only path
|
||||
// optional desktop-only path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,84 +79,127 @@
|
||||
oidcUser = null
|
||||
oidcConfigured = false
|
||||
}
|
||||
|
||||
// OIDC callback state: user just returned from Authentik
|
||||
let showOidcContinue = $derived(oidcConfigured && oidcUser)
|
||||
</script>
|
||||
|
||||
<div class="flex h-svh items-center justify-center p-6">
|
||||
<Card.Root class="w-full max-w-md">
|
||||
<Card.Header>
|
||||
<Card.Title>Connect to Oikos</Card.Title>
|
||||
<Card.Description>Enter the server URL and your access token.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Tabs.Root value={oidcConfigured ? 'oidc' : 'token'}>
|
||||
<Tabs.List class="mb-4 grid w-full grid-cols-2">
|
||||
<Tabs.Trigger value="token">Token</Tabs.Trigger>
|
||||
<Tabs.Trigger value="oidc">Login with Authentik</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="token">
|
||||
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="server-url">Server URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="url"
|
||||
placeholder="https://oikos.hubris.network (leave blank if same-origin)"
|
||||
bind:value={apiUrl}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="token">Token</Label>
|
||||
<Input id="token" type="password" placeholder="bearer token" bind:value={token} />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" disabled={connecting} class="flex-1">
|
||||
{connecting ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
{#if onCancel}
|
||||
<Button type="button" variant="outline" onclick={onCancel}>Cancel</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if existing.token}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
|
||||
Forget saved connection
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="oidc">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if oidcConfigured && oidcUser}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Logged in as <span class="font-medium text-foreground">{oidcUser}</span>
|
||||
</p>
|
||||
<Button type="button" variant="default" onclick={() => onConnected()}>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
|
||||
Log out
|
||||
</Button>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Sign in with your Authentik account to access the control room.
|
||||
</p>
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
<Button type="button" disabled={oidcLoggingIn} onclick={loginWithAuthentik}>
|
||||
{oidcLoggingIn ? 'Redirecting to Authentik…' : 'Login with Authentik'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if existing.token}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
|
||||
Forget saved connection
|
||||
</Button>
|
||||
<div class="relative h-svh overflow-hidden bg-background">
|
||||
<ConfigBackground />
|
||||
|
||||
<div class="relative z-10 flex h-full items-center justify-center p-6">
|
||||
<div class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl">
|
||||
|
||||
<!-- Logo + heading -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<svg viewBox="0 0 91 100" class="h-16 w-16 fill-white/90" aria-hidden="true">
|
||||
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||
</svg>
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Connect to Oikos</h1>
|
||||
<p class="mt-1.5 text-sm text-muted-foreground">Configure your control room connection</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server URL -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="server-url" class="text-xs font-medium">Server URL</Label>
|
||||
<div class="relative">
|
||||
<Server class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
id="server-url"
|
||||
type="url"
|
||||
placeholder="https://oikos.hubris.network"
|
||||
class="pl-9"
|
||||
bind:value={apiUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showOidcContinue}
|
||||
<!-- OIDC authenticated state -->
|
||||
<div class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button type="button" variant="default" class="flex-1" onclick={() => onConnected()}>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
{#if onCancel}
|
||||
<Button type="button" variant="outline" onclick={onCancel}>Cancel</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3">
|
||||
<Separator decorative class="flex-1" />
|
||||
<span class="text-[11px] font-medium uppercase tracking-widest text-muted-foreground/60">authenticate with</span>
|
||||
<Separator decorative class="flex-1" />
|
||||
</div>
|
||||
|
||||
<!-- Auth options: Authentik + token -->
|
||||
<div class="flex w-full gap-3">
|
||||
<!-- Authentik column -->
|
||||
<div class="flex flex-1 flex-col rounded-xl border border-white/5 bg-background/40 p-4">
|
||||
<p class="mb-3 text-center text-xs text-muted-foreground">Single sign-on</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={oidcLoggingIn}
|
||||
onclick={loginWithAuthentik}
|
||||
class="w-full gap-2"
|
||||
>
|
||||
<LogIn class="size-4" />
|
||||
{oidcLoggingIn ? 'Redirecting…' : 'Login with Authentik'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Token column -->
|
||||
<div class="flex flex-1 flex-col rounded-xl border border-white/5 bg-background/40 p-4">
|
||||
<p class="mb-3 text-center text-xs text-muted-foreground">Access token</p>
|
||||
<form
|
||||
class="flex flex-col gap-2.5"
|
||||
onsubmit={(e) => { e.preventDefault(); connect() }}
|
||||
>
|
||||
<div class="relative">
|
||||
<Lock class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Bearer token"
|
||||
class="pl-8 text-sm"
|
||||
bind:value={token}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={connecting} size="sm" class="w-full gap-1.5">
|
||||
{connecting ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Footer: cancel + forget -->
|
||||
{#if !showOidcContinue && (onCancel || existing.token)}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{#if onCancel}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={onCancel}>Cancel</Button>
|
||||
{/if}
|
||||
{#if existing.token}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
|
||||
Forget saved connection
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user