feat(particles): integrate WebGPU for particle background rendering and enhance PocketBase auth handling
- Replaced the existing canvas-based particle rendering with a WebGPU implementation for improved performance and visual quality. - Updated the PocketBase authentication handling to prevent server-side rendering hangs when the auth cookie is absent or invalid. - Enhanced cookie management in the logout process to ensure secure handling based on the request protocol. - Added the `typegpu` dependency to support the new rendering approach.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"pocketbase": "^0.26.8",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"typegpu": "^0.10.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.0",
|
||||
@@ -226,10 +227,14 @@
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"bits-ui": ["bits-ui@2.15.5", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
|
||||
@@ -318,6 +323,8 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
@@ -378,22 +385,34 @@
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinyest": ["tinyest@0.3.1", "", {}, "sha512-SJNnjbvTEo5VmIjsMYpUFL34b9RyaI382r1v7gyVXZpd4VnjIZKMrGk1mphXM4zkhrs3hZfO1Xwv63DoZX50yw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsover-runtime": ["tsover-runtime@0.0.5", "", { "dependencies": { "minimatch": "^9.0.0", "unplugin": "^2.0.0" } }, "sha512-OwpUKI97GvNRVukCE4jtCzUwQvnn6Wyu4a3LikaNF1GXosopXW4J928/EdBsKBxLKv/NP7RjuwhGFkkR1UCdjA=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"typed-binary": ["typed-binary@4.3.3", "", {}, "sha512-W2hLsSzt3k/tg38gDE4Fn/QiwcoqGuUHBc2cb3mXuH7KcYxe/GM57vzW14s2/bawB4R5knGgGq8Xb57vsaJ4Sg=="],
|
||||
|
||||
"typegpu": ["typegpu@0.10.2", "", { "dependencies": { "tinyest": "~0.3.0", "tsover-runtime": "^0.0.5", "typed-binary": "^4.3.1" } }, "sha512-5DZRvNtsbvRM++MONmwxfX0+vYAZgPKnGNCB8nnr3jwXo/gaOJ51WcgKRU8cHvK0RvMikCCVdolwBeEC0B9m7A=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"pocketbase": "^0.26.8",
|
||||
"typegpu": "^0.10.2",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-merge": "^3.4.1"
|
||||
}
|
||||
|
||||
+16
-7
@@ -39,14 +39,22 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
// Load auth store from request cookie
|
||||
event.locals.pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '');
|
||||
const cookieHeader = event.request.headers.get('cookie') || '';
|
||||
event.locals.pb.authStore.loadFromCookie(cookieHeader);
|
||||
|
||||
try {
|
||||
if (event.locals.pb.authStore.isValid) {
|
||||
await event.locals.pb.collection('users').authRefresh();
|
||||
// Avoid hanging SSR when PocketBase is unreachable or no real session exists.
|
||||
// Only attempt refresh when the auth cookie is actually present.
|
||||
const hasPbAuthCookie = /(^|;\s*)pb_auth=/.test(cookieHeader);
|
||||
if (hasPbAuthCookie && event.locals.pb.authStore.isValid) {
|
||||
try {
|
||||
const authRefreshPromise = event.locals.pb.collection('users').authRefresh();
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('PocketBase authRefresh timeout')), 5000)
|
||||
);
|
||||
await Promise.race([authRefreshPromise, timeoutPromise]);
|
||||
} catch {
|
||||
event.locals.pb.authStore.clear();
|
||||
}
|
||||
} catch {
|
||||
event.locals.pb.authStore.clear();
|
||||
}
|
||||
|
||||
event.locals.user = event.locals.pb.authStore.record ?? null;
|
||||
@@ -70,9 +78,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
|
||||
// httpOnly: false so client PocketBase can read auth for realtime subscriptions
|
||||
const isSecureRequest = event.url.protocol === 'https:';
|
||||
response.headers.append(
|
||||
'set-cookie',
|
||||
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax' })
|
||||
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax', secure: isSecureRequest })
|
||||
);
|
||||
|
||||
return response;
|
||||
|
||||
@@ -1,148 +1,48 @@
|
||||
<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;
|
||||
}
|
||||
import { startParticlesBackgroundWebGpu } from './particlesBackgroundWebGpu';
|
||||
|
||||
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);
|
||||
}
|
||||
let gpuDestroy: (() => void) | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || !isDesktop || !canvasEl) return;
|
||||
resize();
|
||||
ctx = canvasEl.getContext('2d');
|
||||
if (ctx) tick();
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
destroyed = false;
|
||||
gpuDestroy = null;
|
||||
|
||||
const run = async () => {
|
||||
let handle: Awaited<ReturnType<typeof startParticlesBackgroundWebGpu>> = null;
|
||||
try {
|
||||
handle = await startParticlesBackgroundWebGpu(canvasEl!, {
|
||||
isDestroyed: () => destroyed
|
||||
});
|
||||
} catch {
|
||||
handle = null;
|
||||
}
|
||||
|
||||
if (destroyed) {
|
||||
handle?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
gpuDestroy = handle.destroy;
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener('resize', resize);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
destroyed = true;
|
||||
if (gpuDestroy) {
|
||||
gpuDestroy();
|
||||
gpuDestroy = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -158,7 +58,7 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
if (gpuDestroy) gpuDestroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* HRM particle background via TypeGPU (same sim as ParticlesBackground.svelte; WebGPU draw).
|
||||
* Dynamically imports typegpu so SSR does not load the WebGPU stack.
|
||||
*/
|
||||
const PARTICLE_COUNT = 80;
|
||||
/** Inner spacing scale; lines fade to zero by this distance */
|
||||
const LINE_DISTANCE = 120;
|
||||
/** Extra distance so lines ease out instead of popping off */
|
||||
const LINE_FADE = 56;
|
||||
const LINE_CUTOFF = LINE_DISTANCE + LINE_FADE;
|
||||
const SPEED = 0.2;
|
||||
const VELOCITY_DRAG = 0.987;
|
||||
const MOUSE_RADIUS = 200;
|
||||
const MOUSE_STRENGTH = 0.08;
|
||||
/** Keep a gentle drift so drag does not freeze particles */
|
||||
const MIN_SPEED = 0.07;
|
||||
|
||||
const MAX_LINE_SEGMENTS = (PARTICLE_COUNT * (PARTICLE_COUNT - 1)) / 2;
|
||||
const MAX_LINE_VERTICES = MAX_LINE_SEGMENTS * 2;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
function initParticles(width: number, height: number): Particle[] {
|
||||
return 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 isAppDark(): boolean {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
/**
|
||||
* Body / canvas clear: apple-gray-50 & apple-gray-900 from app.css
|
||||
* (values are hardcoded to avoid runtime CSS reads in the animation loop).
|
||||
*/
|
||||
function themeColors(dark: boolean) {
|
||||
if (dark) {
|
||||
return {
|
||||
dot: [148 / 255, 163 / 255, 184 / 255, 0.28] as const,
|
||||
line: [148 / 255, 163 / 255, 184 / 255, 0.09] as const,
|
||||
clear: [15 / 255, 15 / 255, 17 / 255, 1] as const
|
||||
};
|
||||
}
|
||||
return {
|
||||
dot: [100 / 255, 116 / 255, 139 / 255, 0.22] as const,
|
||||
line: [100 / 255, 116 / 255, 139 / 255, 0.075] as const,
|
||||
clear: [245 / 255, 245 / 255, 247 / 255, 1] as const
|
||||
};
|
||||
}
|
||||
|
||||
export type ParticlesBackgroundWebGpuHandle = {
|
||||
/** Stop animation and release listeners. */
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns handle, or null if WebGPU is unavailable or init fails.
|
||||
*/
|
||||
export async function startParticlesBackgroundWebGpu(
|
||||
canvas: HTMLCanvasElement,
|
||||
options: { isDestroyed: () => boolean }
|
||||
): Promise<ParticlesBackgroundWebGpuHandle | null> {
|
||||
// `Navigator.gpu` is not typed in this project by default.
|
||||
// WebGPU support is still runtime-checked before we import typegpu.
|
||||
const nav = navigator as any;
|
||||
if (!nav?.gpu) return null;
|
||||
|
||||
const mod = await import('typegpu');
|
||||
const tgpu = mod.default;
|
||||
const d = mod.d;
|
||||
|
||||
const Globals = d.struct({
|
||||
canvasSize: d.vec2f,
|
||||
dotColor: d.vec4f,
|
||||
lineColor: d.vec4f
|
||||
});
|
||||
|
||||
let particles: Particle[] = [];
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
let mouseActive = false;
|
||||
let raf = 0;
|
||||
|
||||
const root = await tgpu.init();
|
||||
const format = nav.gpu.getPreferredCanvasFormat();
|
||||
const context = root.configureContext({ canvas, format, alphaMode: 'premultiplied' });
|
||||
|
||||
const globals = root.createUniform(Globals, {
|
||||
canvasSize: d.vec2f(1, 1),
|
||||
dotColor: d.vec4f(1, 1, 1, 1),
|
||||
lineColor: d.vec4f(1, 1, 1, 1)
|
||||
});
|
||||
|
||||
const particlesSchema = d.arrayOf(d.vec2f, PARTICLE_COUNT);
|
||||
const lineVertsSchema = d.arrayOf(d.vec4f, MAX_LINE_VERTICES);
|
||||
|
||||
const particlePositions = root.createReadonly(particlesSchema);
|
||||
const lineVertexBuffer = root.createReadonly(lineVertsSchema);
|
||||
|
||||
const blendTarget = {
|
||||
format,
|
||||
blend: {
|
||||
color: {
|
||||
srcFactor: 'src-alpha' as const,
|
||||
dstFactor: 'one-minus-src-alpha' as const,
|
||||
operation: 'add' as const
|
||||
},
|
||||
alpha: {
|
||||
srcFactor: 'one' as const,
|
||||
dstFactor: 'one-minus-src-alpha' as const,
|
||||
operation: 'add' as const
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const lineVert = tgpu
|
||||
.vertexFn({
|
||||
in: { vi: d.builtin.vertexIndex },
|
||||
out: { pos: d.builtin.position, lineAlpha: d.location(0, d.f32) }
|
||||
})
|
||||
`{
|
||||
let p = lineVerts[u32(in.vi)];
|
||||
let ndc = vec2f(
|
||||
p.x / globals.canvasSize.x * 2.0 - 1.0,
|
||||
-(p.y / globals.canvasSize.y * 2.0 - 1.0)
|
||||
);
|
||||
return Out(vec4f(ndc, 0.0, 1.0), p.z);
|
||||
}`
|
||||
.$uses({ globals, lineVerts: lineVertexBuffer });
|
||||
|
||||
const lineFrag = tgpu
|
||||
.fragmentFn({
|
||||
in: { lineAlpha: d.location(0, d.f32) },
|
||||
out: d.vec4f
|
||||
})
|
||||
`{
|
||||
let c = globals.lineColor;
|
||||
return vec4f(c.xyz, c.w * in.lineAlpha);
|
||||
}`
|
||||
.$uses({ globals });
|
||||
|
||||
const linePipeline = root.createRenderPipeline({
|
||||
primitive: { topology: 'line-list' },
|
||||
vertex: lineVert,
|
||||
fragment: lineFrag,
|
||||
targets: blendTarget
|
||||
});
|
||||
|
||||
const pointVert = tgpu
|
||||
.vertexFn({
|
||||
in: {
|
||||
vi: d.builtin.vertexIndex,
|
||||
ii: d.builtin.instanceIndex
|
||||
},
|
||||
out: { pos: d.builtin.position, quadUv: d.location(0, d.vec2f) }
|
||||
})
|
||||
`{
|
||||
let p = particles[in.ii];
|
||||
let vid = u32(in.vi);
|
||||
let corner = array<vec2f, 6>(
|
||||
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
|
||||
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0)
|
||||
);
|
||||
let uv = corner[vid];
|
||||
let s = vec2f(1.45, 1.45) / globals.canvasSize * 2.0;
|
||||
let ndcCenter = vec2f(
|
||||
p.x / globals.canvasSize.x * 2.0 - 1.0,
|
||||
-(p.y / globals.canvasSize.y * 2.0 - 1.0)
|
||||
);
|
||||
let ndc = ndcCenter + uv * s;
|
||||
return Out(vec4f(ndc, 0.0, 1.0), uv);
|
||||
}`
|
||||
.$uses({ globals, particles: particlePositions });
|
||||
|
||||
const pointFrag = tgpu
|
||||
.fragmentFn({
|
||||
in: { quadUv: d.location(0, d.vec2f) },
|
||||
out: d.vec4f
|
||||
})
|
||||
`{
|
||||
let r = length(in.quadUv);
|
||||
let a = globals.dotColor.a * smoothstep(1.08, 0.22, r);
|
||||
return vec4f(globals.dotColor.xyz, a);
|
||||
}`
|
||||
.$uses({ globals });
|
||||
|
||||
const pointPipeline = root.createRenderPipeline({
|
||||
primitive: { topology: 'triangle-list' },
|
||||
vertex: pointVert,
|
||||
fragment: pointFrag,
|
||||
targets: blendTarget
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
const dpr = Math.min(2, globalThis.devicePixelRatio || 1);
|
||||
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const cssW = canvas.clientWidth;
|
||||
const cssH = canvas.clientHeight;
|
||||
particles = initParticles(cssW, cssH);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
mouseActive =
|
||||
e.clientX >= 0 &&
|
||||
e.clientY >= 0 &&
|
||||
e.clientX <= globalThis.innerWidth &&
|
||||
e.clientY <= globalThis.innerHeight;
|
||||
};
|
||||
|
||||
const onThemeClass = () => {
|
||||
const t = themeColors(isAppDark());
|
||||
globals.writePartial({
|
||||
dotColor: d.vec4f(t.dot[0], t.dot[1], t.dot[2], t.dot[3]),
|
||||
lineColor: d.vec4f(t.line[0], t.line[1], t.line[2], t.line[3])
|
||||
});
|
||||
};
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
if (!options.isDestroyed()) onThemeClass();
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
globalThis.addEventListener('resize', onResize);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
onResize();
|
||||
onThemeClass();
|
||||
|
||||
const lineScratch: [number, number, number][] = [];
|
||||
|
||||
const frame = () => {
|
||||
if (options.isDestroyed()) return;
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
const cssW = canvas.clientWidth;
|
||||
const cssH = canvas.clientHeight;
|
||||
const dark = isAppDark();
|
||||
const pal = themeColors(dark);
|
||||
|
||||
globals.writePartial({
|
||||
canvasSize: d.vec2f(cssW, cssH),
|
||||
dotColor: d.vec4f(pal.dot[0], pal.dot[1], pal.dot[2], pal.dot[3]),
|
||||
lineColor: d.vec4f(pal.line[0], pal.line[1], pal.line[2], pal.line[3])
|
||||
});
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]!;
|
||||
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;
|
||||
}
|
||||
}
|
||||
let vx = p.vx * VELOCITY_DRAG;
|
||||
let vy = p.vy * VELOCITY_DRAG;
|
||||
const sp = Math.hypot(vx, vy);
|
||||
if (sp < MIN_SPEED) {
|
||||
if (sp > 1e-12) {
|
||||
const s = MIN_SPEED / sp;
|
||||
vx *= s;
|
||||
vy *= s;
|
||||
} else {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
vx = Math.cos(a) * MIN_SPEED;
|
||||
vy = Math.sin(a) * MIN_SPEED;
|
||||
}
|
||||
}
|
||||
p.vx = vx;
|
||||
p.vy = vy;
|
||||
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));
|
||||
}
|
||||
|
||||
lineScratch.length = 0;
|
||||
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 = Math.hypot(dx, dy);
|
||||
if (dist >= LINE_CUTOFF) continue;
|
||||
const t = Math.max(0, 1 - dist / LINE_CUTOFF);
|
||||
/** Ease-out so lines soften as pairs separate */
|
||||
const alpha = t ** 1.55;
|
||||
if (alpha < 0.003) continue;
|
||||
lineScratch.push([a.x, a.y, alpha], [b.x, b.y, alpha]);
|
||||
}
|
||||
}
|
||||
|
||||
const posWrite = particles.map((p) => d.vec2f(p.x, p.y));
|
||||
particlePositions.write(posWrite);
|
||||
|
||||
const linePad = Array.from({ length: MAX_LINE_VERTICES }, (_, k) => {
|
||||
const v = lineScratch[k];
|
||||
return v ? d.vec4f(v[0]!, v[1]!, v[2]!, 0) : d.vec4f(0, 0, 0, 0);
|
||||
});
|
||||
lineVertexBuffer.write(linePad);
|
||||
|
||||
const clear = pal.clear;
|
||||
const attachment = {
|
||||
view: context,
|
||||
clearValue: { r: clear[0], g: clear[1], b: clear[2], a: clear[3] }
|
||||
};
|
||||
|
||||
pointPipeline.withColorAttachment(attachment).draw(6, PARTICLE_COUNT);
|
||||
|
||||
const lineVertCount = lineScratch.length;
|
||||
if (lineVertCount > 0) {
|
||||
linePipeline.withColorAttachment({ view: context, loadOp: 'load' as const }).draw(lineVertCount);
|
||||
}
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
cancelAnimationFrame(raf);
|
||||
globalThis.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
themeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,9 @@
|
||||
import './app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { initRealtime } from '$lib/realtime';
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import ParticlesBackground from '$lib/components/effects/ParticlesBackground.svelte';
|
||||
import HeaderDropdown from './HeaderDropdown.client.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let cleanupRealtime: (() => void) | undefined;
|
||||
@@ -86,48 +83,7 @@
|
||||
|
||||
<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.Trigger
|
||||
class="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
asChild
|
||||
>
|
||||
<button type="button" class="rounded-full ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<Avatar.Root class="size-9">
|
||||
<Avatar.Image
|
||||
src={(user as { avatar?: string })?.avatar}
|
||||
alt={(user as { name?: string })?.name ?? 'User'}
|
||||
/>
|
||||
<Avatar.Fallback class="bg-primary text-primary-foreground text-sm font-medium">
|
||||
{getInitials(user as { name?: string; email?: string })}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-56">
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-1.5 text-sm">
|
||||
<span class="text-muted-foreground flex items-center gap-2">
|
||||
<MoonIcon class="size-4" />
|
||||
Dark mode
|
||||
</span>
|
||||
<Switch
|
||||
checked={darkmode}
|
||||
onCheckedChange={toggleDarkmode}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={async () => {
|
||||
await fetch('/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
goto('/login');
|
||||
}}
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
Log out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</header>
|
||||
<HeaderDropdown user={user} darkmode={darkmode} toggleDarkmode={toggleDarkmode} />
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
|
||||
export let user: any = null;
|
||||
export let darkmode: boolean;
|
||||
export let toggleDarkmode: (checked: boolean) => Promise<void> | void;
|
||||
|
||||
function getInitials(u: any): string {
|
||||
if (!u) return '?';
|
||||
if (u.name && typeof u.name === 'string') {
|
||||
const parts = u.name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
if (parts[0]) return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
const email = u.email && typeof u.email === 'string' ? u.email : '';
|
||||
const local = email.split('@')[0] ?? '';
|
||||
if (local.length >= 2) return local.slice(0, 2).toUpperCase();
|
||||
return local ? local[0].toUpperCase() : '?';
|
||||
}
|
||||
</script>
|
||||
|
||||
<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.Trigger class="rounded-full outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<Avatar.Root class="size-9">
|
||||
<Avatar.Image src={user?.avatar} alt={user?.name ?? 'User'} />
|
||||
<Avatar.Fallback class="bg-primary text-primary-foreground text-sm font-medium">
|
||||
{getInitials(user)}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-56">
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-1.5 text-sm">
|
||||
<span class="text-muted-foreground flex items-center gap-2">
|
||||
<MoonIcon class="size-4" />
|
||||
Dark mode
|
||||
</span>
|
||||
<Switch checked={darkmode} onCheckedChange={toggleDarkmode} />
|
||||
</div>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={async () => {
|
||||
await fetch('/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
goto('/login');
|
||||
}}
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
Log out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</header>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ locals }) => {
|
||||
export const POST: RequestHandler = async ({ locals, url }) => {
|
||||
locals.pb.authStore.clear();
|
||||
return redirect(303, '/login', {
|
||||
headers: {
|
||||
'set-cookie': locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax' })
|
||||
'set-cookie': locals.pb.authStore.exportToCookie({
|
||||
httpOnly: false,
|
||||
sameSite: 'Lax',
|
||||
secure: url.protocol === 'https:'
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user