diff --git a/bun.lock b/bun.lock index e34c28c..79a8b10 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index ae78d89..9f6f544 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5696383..0fc2034 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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((_, 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; diff --git a/src/lib/components/effects/ParticlesBackground.svelte b/src/lib/components/effects/ParticlesBackground.svelte index 119838c..b1d68b2 100644 --- a/src/lib/components/effects/ParticlesBackground.svelte +++ b/src/lib/components/effects/ParticlesBackground.svelte @@ -1,148 +1,48 @@ diff --git a/src/lib/components/effects/particlesBackgroundWebGpu.ts b/src/lib/components/effects/particlesBackgroundWebGpu.ts new file mode 100644 index 0000000..e5ea0cd --- /dev/null +++ b/src/lib/components/effects/particlesBackgroundWebGpu.ts @@ -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 { + // `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(-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(); + } + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3ea5976..7420163 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@
{#if showHeader} -
- - - - - -
- - - Dark mode - - -
- - { - await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); - goto('/login'); - }} - > - - Log out - -
-
-
+ {/if} {@render children()} diff --git a/src/routes/HeaderDropdown.client.svelte b/src/routes/HeaderDropdown.client.svelte new file mode 100644 index 0000000..36f4dce --- /dev/null +++ b/src/routes/HeaderDropdown.client.svelte @@ -0,0 +1,58 @@ + + +
+ + + + + + {getInitials(user)} + + + + +
+ + + Dark mode + + +
+ + { + await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); + goto('/login'); + }} + > + + Log out + +
+
+
+ diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts index e73422d..6fec8fb 100644 --- a/src/routes/logout/+server.ts +++ b/src/routes/logout/+server.ts @@ -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:' + }) } }); };