feat(layout): add ParticlesBackground component and conditional rendering based on browser environment
- Introduced the `ParticlesBackground` component to enhance the visual experience in the layout. - Updated the header rendering logic to conditionally display based on the browser environment, ensuring compatibility with server-side rendering.
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
/** Number of particles (tuned for desktop background) */
|
||||||
|
const PARTICLE_COUNT = 80;
|
||||||
|
/** Max distance to draw a line between two particles */
|
||||||
|
const LINE_DISTANCE = 120;
|
||||||
|
/** Particle base speed */
|
||||||
|
const SPEED = 0.2;
|
||||||
|
/** Mouse influence: radius and repulsion strength */
|
||||||
|
const MOUSE_RADIUS = 200;
|
||||||
|
const MOUSE_STRENGTH = 0.08;
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvasEl: HTMLCanvasElement | null = $state(null);
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
let animationId = 0;
|
||||||
|
let isDesktop = $state(false);
|
||||||
|
/** Mouse position in viewport coords (updated on mousemove, read in draw) */
|
||||||
|
let mouseX = 0;
|
||||||
|
let mouseY = 0;
|
||||||
|
let mouseActive = false;
|
||||||
|
|
||||||
|
function initParticles(width: number, height: number): void {
|
||||||
|
particles = Array.from({ length: PARTICLE_COUNT }, () => ({
|
||||||
|
x: Math.random() * width,
|
||||||
|
y: Math.random() * height,
|
||||||
|
vx: (Math.random() - 0.5) * SPEED,
|
||||||
|
vy: (Math.random() - 0.5) * SPEED,
|
||||||
|
radius: Math.random() * 1.2 + 0.6
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(): void {
|
||||||
|
if (!ctx || !canvasEl) return;
|
||||||
|
const cssW = canvasEl.offsetWidth;
|
||||||
|
const cssH = canvasEl.offsetHeight;
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
|
// Particle color: subtle, theme-aware
|
||||||
|
const dotColor = isDark ? 'rgba(148, 163, 184, 0.35)' : 'rgba(100, 116, 139, 0.28)';
|
||||||
|
const lineColor = isDark ? 'rgba(148, 163, 184, 0.12)' : 'rgba(100, 116, 139, 0.1)';
|
||||||
|
|
||||||
|
// Update and draw particles (coordinates in CSS pixels; ctx is scaled by dpr in resize)
|
||||||
|
for (let i = 0; i < particles.length; i++) {
|
||||||
|
const p = particles[i];
|
||||||
|
// Gentle repulsion from mouse
|
||||||
|
if (mouseActive) {
|
||||||
|
const dx = p.x - mouseX;
|
||||||
|
const dy = p.y - mouseY;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist > 0 && dist < MOUSE_RADIUS) {
|
||||||
|
const falloff = 1 - dist / MOUSE_RADIUS;
|
||||||
|
const force = (MOUSE_STRENGTH * falloff) / dist;
|
||||||
|
p.vx += dx * force;
|
||||||
|
p.vy += dy * force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
if (p.x < 0 || p.x > cssW) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > cssH) p.vy *= -1;
|
||||||
|
p.x = Math.max(0, Math.min(cssW, p.x));
|
||||||
|
p.y = Math.max(0, Math.min(cssH, p.y));
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = dotColor;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw lines between nearby particles
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
for (let i = 0; i < particles.length; i++) {
|
||||||
|
for (let j = i + 1; j < particles.length; j++) {
|
||||||
|
const dx = particles[i].x - particles[j].x;
|
||||||
|
const dy = particles[i].y - particles[j].y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist < LINE_DISTANCE) {
|
||||||
|
const alpha = 1 - dist / LINE_DISTANCE;
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(particles[i].x, particles[i].y);
|
||||||
|
ctx.lineTo(particles[j].x, particles[j].y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(): void {
|
||||||
|
draw();
|
||||||
|
animationId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(): void {
|
||||||
|
if (!canvasEl || !browser) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
const w = canvasEl.offsetWidth;
|
||||||
|
const h = canvasEl.offsetHeight;
|
||||||
|
canvasEl.width = w * dpr;
|
||||||
|
canvasEl.height = h * dpr;
|
||||||
|
const ctx2 = canvasEl.getContext('2d');
|
||||||
|
if (ctx2) {
|
||||||
|
ctx2.scale(dpr, dpr);
|
||||||
|
ctx = ctx2;
|
||||||
|
}
|
||||||
|
initParticles(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent): void {
|
||||||
|
mouseX = e.clientX;
|
||||||
|
mouseY = e.clientY;
|
||||||
|
// Only react when cursor is inside viewport (document mouseleave doesn't fire when leaving window)
|
||||||
|
mouseActive =
|
||||||
|
e.clientX >= 0 &&
|
||||||
|
e.clientY >= 0 &&
|
||||||
|
e.clientX <= (browser ? window.innerWidth : 0) &&
|
||||||
|
e.clientY <= (browser ? window.innerHeight : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !isDesktop || !canvasEl) return;
|
||||||
|
resize();
|
||||||
|
ctx = canvasEl.getContext('2d');
|
||||||
|
if (ctx) tick();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const mq = window.matchMedia('(min-width: 1024px)');
|
||||||
|
isDesktop = mq.matches;
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
isDesktop = e.matches;
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if browser && isDesktop}
|
||||||
|
<div
|
||||||
|
class="particles-bg fixed inset-0 pointer-events-none z-0 overflow-hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasEl}
|
||||||
|
class="absolute inset-0 w-full h-full"
|
||||||
|
style="width: 100%; height: 100%;"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||||
|
import ParticlesBackground from '$lib/components/effects/ParticlesBackground.svelte';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
let cleanupRealtime: (() => void) | undefined;
|
let cleanupRealtime: (() => void) | undefined;
|
||||||
@@ -78,8 +80,13 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if showHeader}
|
{#if browser && showHeader}
|
||||||
<header class="border-border flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4 md:px-6">
|
<ParticlesBackground />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative z-[1] flex min-h-screen flex-col">
|
||||||
|
{#if showHeader}
|
||||||
|
<header class="border-border flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4 md:px-6">
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
class="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
class="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
@@ -121,6 +128,7 @@
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</header>
|
</header>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user