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>
|
||||
Reference in New Issue
Block a user