feat(particles): integrate WebGPU for particle background rendering and enhance PocketBase auth handling
CI / build (push) Has been skipped
CI / deploy (push) Successful in 51s

- 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:
eewing
2026-03-25 11:04:53 -05:00
parent 08728eca90
commit 8cc7a75ec4
8 changed files with 489 additions and 190 deletions
+19
View File
@@ -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=="],
+1
View File
@@ -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"
}
+14 -5
View File
@@ -39,15 +39,23 @@ 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);
// 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 {
if (event.locals.pb.authStore.isValid) {
await event.locals.pb.collection('users').authRefresh();
}
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();
}
}
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();
}
};
}
+2 -46
View File
@@ -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()}
+58
View File
@@ -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>
+6 -2
View File
@@ -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:'
})
}
});
};