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",
|
"pocketbase": "^0.26.8",
|
||||||
"svelte-sonner": "^1.0.7",
|
"svelte-sonner": "^1.0.7",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
|
"typegpu": "^0.10.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "^3.10.0",
|
||||||
@@ -226,10 +227,14 @@
|
|||||||
|
|
||||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
@@ -378,22 +385,34 @@
|
|||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"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=="],
|
"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=="],
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
|
"typegpu": "^0.10.2",
|
||||||
"svelte-sonner": "^1.0.7",
|
"svelte-sonner": "^1.0.7",
|
||||||
"tailwind-merge": "^3.4.1"
|
"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);
|
event.locals.pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
// Load auth store from request cookie
|
// 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 {
|
// Avoid hanging SSR when PocketBase is unreachable or no real session exists.
|
||||||
if (event.locals.pb.authStore.isValid) {
|
// Only attempt refresh when the auth cookie is actually present.
|
||||||
await event.locals.pb.collection('users').authRefresh();
|
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;
|
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);
|
const response = await resolve(event);
|
||||||
|
|
||||||
// httpOnly: false so client PocketBase can read auth for realtime subscriptions
|
// httpOnly: false so client PocketBase can read auth for realtime subscriptions
|
||||||
|
const isSecureRequest = event.url.protocol === 'https:';
|
||||||
response.headers.append(
|
response.headers.append(
|
||||||
'set-cookie',
|
'set-cookie',
|
||||||
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax' })
|
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax', secure: isSecureRequest })
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1,148 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { startParticlesBackgroundWebGpu } from './particlesBackgroundWebGpu';
|
||||||
/** 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 canvasEl: HTMLCanvasElement | null = $state(null);
|
||||||
let ctx: CanvasRenderingContext2D | null = null;
|
|
||||||
let particles: Particle[] = [];
|
|
||||||
let animationId = 0;
|
|
||||||
let isDesktop = $state(false);
|
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 {
|
let gpuDestroy: (() => void) | null = null;
|
||||||
particles = Array.from({ length: PARTICLE_COUNT }, () => ({
|
let destroyed = false;
|
||||||
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(() => {
|
$effect(() => {
|
||||||
if (!browser || !isDesktop || !canvasEl) return;
|
if (!browser || !isDesktop || !canvasEl) return;
|
||||||
resize();
|
|
||||||
ctx = canvasEl.getContext('2d');
|
destroyed = false;
|
||||||
if (ctx) tick();
|
gpuDestroy = null;
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
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 () => {
|
return () => {
|
||||||
cancelAnimationFrame(animationId);
|
destroyed = true;
|
||||||
window.removeEventListener('resize', resize);
|
if (gpuDestroy) {
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
gpuDestroy();
|
||||||
|
gpuDestroy = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +58,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
cancelAnimationFrame(animationId);
|
if (gpuDestroy) gpuDestroy();
|
||||||
});
|
});
|
||||||
</script>
|
</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 './app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { initRealtime } from '$lib/realtime';
|
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 { 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 ParticlesBackground from '$lib/components/effects/ParticlesBackground.svelte';
|
||||||
|
import HeaderDropdown from './HeaderDropdown.client.svelte';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
let cleanupRealtime: (() => void) | undefined;
|
let cleanupRealtime: (() => void) | undefined;
|
||||||
@@ -86,48 +83,7 @@
|
|||||||
|
|
||||||
<div class="relative z-[1] flex min-h-screen flex-col">
|
<div class="relative z-[1] flex min-h-screen flex-col">
|
||||||
{#if showHeader}
|
{#if showHeader}
|
||||||
<header class="border-border flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4 md:px-6">
|
<HeaderDropdown user={user} darkmode={darkmode} toggleDarkmode={toggleDarkmode} />
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{@render children()}
|
{@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 { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ locals }) => {
|
export const POST: RequestHandler = async ({ locals, url }) => {
|
||||||
locals.pb.authStore.clear();
|
locals.pb.authStore.clear();
|
||||||
return redirect(303, '/login', {
|
return redirect(303, '/login', {
|
||||||
headers: {
|
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