8 Commits

Author SHA1 Message Date
095acc216a remove example data 2026-03-14 18:37:58 +01:00
288616548a no scroll trigger markers 2026-03-14 18:37:02 +01:00
57978d81e1 scroll triggers 2026-03-14 18:35:04 +01:00
b5291caa6e working animations 2026-03-13 22:24:12 +01:00
4293eef824 working animations 2026-03-13 22:18:44 +01:00
d71bb5d26e css reset 2026-03-13 19:44:49 +01:00
e0e32d16e2 reasonable animation system 2026-03-13 19:42:22 +01:00
166ae50c49 music and animation system 2026-03-13 15:49:53 +01:00
34 changed files with 867 additions and 601 deletions

106
bun.lock
View File

@@ -5,8 +5,6 @@
"": {
"name": "gregorlohaus.com",
"dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -51,7 +49,7 @@
"@trpc/react-query": "^11.12.0",
"@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11",
"ai": "^6.0.116",
"@uploadthing/react": "^7.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -61,7 +59,6 @@
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gsap": "^3.14.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
@@ -84,6 +81,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss-motion": "^1.1.1",
"type-fest": "^5.4.4",
"uploadthing": "^7.7.4",
"vaul": "^1.1.2",
"zod": "^4.3.6",
},
@@ -122,16 +120,6 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.118", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.19", "ai": "6.0.116", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-fBAix8Jftxse6/2YJnOFkwW1/O6EQK4DK68M9DlFmZGAzBmsaHXEPVS77sVIlkaOWCy11bE7434NAVXRY+3OsQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
@@ -286,6 +274,8 @@
"@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
"@effect/platform": ["@effect/platform@0.90.3", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.33.0", "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.17.7" } }, "sha512-XvQ37yzWQKih4Du2CYladd1i/MzqtgkTPNCaN6Ku6No4CK83hDtXIV/rP03nEoBg2R3Pqgz6gGWmE2id2G81HA=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.16", "", {}, "sha512-mZkZfOd9OqTMHsK+1cje8OSzfAQcpD7JmILXTl5ahdempjUDdmg4euf1biDex5/LfQIDJ3gvCu6qDgdnDxfJmA=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -492,6 +482,18 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
@@ -534,7 +536,7 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -724,7 +726,7 @@
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.4", "", {}, "sha512-d3IxtzLo7P1oZ8s8YNvxzBUXRXojSut8pbPrTYtzsc5sn4+53jVqbk66pQerSZbZSJZQux6LkclB/+8IDordHg=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -954,7 +956,11 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@uploadthing/mime-types": ["@uploadthing/mime-types@0.3.6", "", {}, "sha512-t3tTzgwFV9+1D7lNDYc7Lr7kBwotHaX0ZsvoCGe7xGnXKo9z0jG2Sjl/msll12FeoLj77nyhsxevXyGpQDBvLg=="],
"@uploadthing/react": ["@uploadthing/react@7.3.3", "", { "dependencies": { "@uploadthing/shared": "7.1.10", "file-selector": "0.6.0" }, "peerDependencies": { "next": "*", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "uploadthing": "^7.2.0" }, "optionalPeers": ["next"] }, "sha512-GhKbK42jL2Qs7OhRd2Z6j0zTLsnJTRJH31nR7RZnUYVoRh2aS/NabMAnHBNqfunIAGXVaA717Pvzq7vtxuPTmQ=="],
"@uploadthing/shared": ["@uploadthing/shared@7.1.10", "", { "dependencies": { "@uploadthing/mime-types": "0.3.6", "effect": "3.17.7", "sqids": "^0.3.0" } }, "sha512-R/XSA3SfCVnLIzFpXyGaKPfbwlYlWYSTuGjTFHuJhdAomuBuhopAHLh2Ois5fJibAHzi02uP1QCKbgTAdmArqg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
@@ -982,8 +988,6 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@6.0.116", "", { "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -1026,16 +1030,12 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -1048,8 +1048,6 @@
"bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1236,12 +1234,12 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@3.17.7", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-dpt0ONUn3zzAuul6k4nC/coTTw27AL5nhkORXgTi6NfMPzqWYa1M05oKmOMTxpVSTKepqXVcW9vIwkuaaqx9zA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
@@ -1310,6 +1308,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
@@ -1332,10 +1332,14 @@
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-selector": ["file-selector@0.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@@ -1360,10 +1364,6 @@
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -1396,14 +1396,6 @@
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"googleapis": ["googleapis@171.4.0", "", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg=="],
"googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -1628,12 +1620,8 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -1648,10 +1636,6 @@
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@@ -1834,8 +1818,14 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="],
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
"msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="],
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -1858,6 +1848,8 @@
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
@@ -1982,7 +1974,7 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
@@ -2084,8 +2076,6 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -2096,8 +2086,6 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@@ -2152,6 +2140,8 @@
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"sqids": ["sqids@0.3.0", "", {}, "sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -2196,8 +2186,6 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
@@ -2214,8 +2202,6 @@
"test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -2296,7 +2282,7 @@
"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=="],
"url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="],
"uploadthing": ["uploadthing@7.7.4", "", { "dependencies": { "@effect/platform": "0.90.3", "@standard-schema/spec": "1.0.0-beta.4", "@uploadthing/mime-types": "0.3.6", "@uploadthing/shared": "7.1.10", "effect": "3.17.7" }, "peerDependencies": { "express": "*", "h3": "*", "tailwindcss": "^3.0.0 || ^4.0.0-beta.0" }, "optionalPeers": ["express", "h3", "tailwindcss"] }, "sha512-rlK/4JWHW5jP30syzWGBFDDXv3WJDdT8gn9OoxRJmXLoXi94hBmyyjxihGlNrKhBc81czyv8TkzMioe/OuKGfA=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -2500,6 +2486,8 @@
"@uiw/react-markdown-preview/rehype-prism-plus": ["rehype-prism-plus@2.0.0", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ=="],
"@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -2520,6 +2508,8 @@
"cssstyle/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -2538,6 +2528,8 @@
"jest-circus/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="],
"jest-circus/pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
"jest-config/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="],
"jest-diff/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="],

View File

@@ -19,8 +19,6 @@
"test": "vitest --typecheck"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -65,7 +63,7 @@
"@trpc/react-query": "^11.12.0",
"@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11",
"ai": "^6.0.116",
"@uploadthing/react": "^7.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -75,7 +73,6 @@
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gsap": "^3.14.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
@@ -98,6 +95,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss-motion": "^1.1.1",
"type-fest": "^5.4.4",
"uploadthing": "^7.7.4",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},

View File

@@ -1,32 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
import ChatInterface from '~/app/chat/_components/ChatInterface'
type DBMessage = {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatModalProps {
sessionId: string
initialMessages: DBMessage[]
}
export default function ChatModal({ sessionId, initialMessages }: ChatModalProps) {
const router = useRouter()
return (
<Dialog open onOpenChange={() => router.back()}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-4 border-b shrink-0">
<DialogTitle>AI Recruiter</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<ChatInterface sessionId={sessionId} initialMessages={initialMessages} />
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,34 +0,0 @@
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
import ChatModal from './_components/ChatModal'
export default async function ChatModalPage() {
const { userId } = await auth()
if (!userId) redirect('/')
let session = await db
.select()
.from(chatSession)
.where(eq(chatSession.userId, userId))
.orderBy(desc(chatSession.createdAt))
.limit(1)
.then((r) => r[0])
if (!session) {
const [created] = await db.insert(chatSession).values({ userId }).returning()
session = created
}
if (!session) redirect('/')
const messages = await db
.select()
.from(chatMessage)
.where(eq(chatMessage.sessionId, session.id))
.orderBy(asc(chatMessage.createdAt))
return <ChatModal sessionId={session.id} initialMessages={messages} />
}

View File

@@ -0,0 +1,30 @@
import { useGSAP } from "@gsap/react";
import { useRef, type ReactNode } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText";
import gsap from 'gsap'
const AnimateTextIn = ({children,animation="type",position}:{children:ReactNode,animation?:"type"|"slide",position:gsap.Position}) => {
const el = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext();
useGSAP(() => {
const rect = el.current?.getBoundingClientRect()
const isInView = rect && rect.top < window.innerHeight
const chars = new SplitText(el.current,{type:'chars'})
gsapContext?.addAnimation(gsap.to(el.current,{opacity:100, duration:0}),0)
const fromVars = animation === "slide"
? {opacity:0, x:-10, duration: 0.2, stagger: {each: 0.08}, ease:'bounce.inOut'}
: {opacity:0, duration: 0.01, stagger: {each: 0.04}, ease: 'bounce.inOut'}
if (isInView) {
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars),position)
} else {
gsap.from(chars.chars, { ...fromVars, scrollTrigger: { trigger: el.current, start: 'top 85%', scroller: gsapContext?.getScroller() } })
}
}, { dependencies: [] })
return (
<div ref={el} className="opacity-0">
{children}
</div>
)
}
export default AnimateTextIn;

View File

@@ -0,0 +1,302 @@
"use client";
import React, { useRef, useEffect, useCallback, useState } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { useTheme } from "next-themes";
/* ─────────────────────────────────────────────
* Config — grayscale palettes
* ───────────────────────────────────────────── */
const PALETTES = {
dark: {
base: "#0a0a0a",
particles: [
"rgba(255,255,255,0.70)",
"rgba(255,255,255,0.45)",
"rgba(180,180,180,0.50)",
"rgba(200,200,200,0.35)",
"rgba(255,255,255,0.22)",
],
},
light: {
base: "#f5f5f5",
particles: [
"rgba(0,0,0,0.55)",
"rgba(0,0,0,0.35)",
"rgba(60,60,60,0.40)",
"rgba(80,80,80,0.25)",
"rgba(0,0,0,0.18)",
],
},
} as const;
/* ─────────────────────────────────────────────
* Helpers
* ───────────────────────────────────────────── */
const isMobileDevice = (): boolean => {
if (typeof window === "undefined") return false;
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
/* ─────────────────────────────────────────────
* Particle
* ───────────────────────────────────────────── */
interface Particle {
angle: number;
radius: number;
speed: number;
size: number;
colorIndex: number;
wobbleAmp: number;
wobbleSpeed: number;
wobblePhase: number;
}
const spawnParticle = (): Particle => ({
angle: rand(0, Math.PI * 2),
radius: rand(30, 240),
speed: rand(0.003, 0.002) * (Math.random() > 0.5 ? 1 : -1),
size: rand(1.2, 4),
colorIndex: Math.floor(rand(0, 5)),
wobbleAmp: rand(6, 30),
wobbleSpeed: rand(0.008, 0.035),
wobblePhase: rand(0, Math.PI * 2),
});
/* ─────────────────────────────────────────────
* Component
* ───────────────────────────────────────────── */
interface AnimatedBackgroundContainerProps {
children: React.ReactNode;
className?: string;
/** Number of orbiting particles. Default 60 */
particleCount?: number;
/** Max orbit radius in px — controls how far particles spread from the cursor. Default 240 */
orbitRadius?: number;
/** How quickly particles catch up to cursor (01). Default 0.06 */
followSpeed?: number;
/** Speed multiplier for mobile random anchor drift. Default 1 */
mobileSpeed?: number;
}
export default function AnimatedBackgroundContainer({
children,
className = "",
particleCount = 60,
orbitRadius = 240,
followSpeed = 0.06,
mobileSpeed = 1,
}: AnimatedBackgroundContainerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mousePos = useRef({ x: 0, y: 0 });
const smoothMouse = useRef({ x: 0, y: 0 });
const mobileAnchor = useRef({ x: 0, y: 0 });
const mobileTarget = useRef({ x: 0, y: 0 });
const isMobile = useRef(false);
const particles = useRef<Particle[]>([]);
const frame = useRef(0);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
let isDark = resolvedTheme === "dark";
if (resolvedTheme == undefined) {
isDark = true;
}
const palette = isDark ? PALETTES.dark : PALETTES.light;
/* Spawn particles */
useEffect(() => {
const minR = Math.max(10, orbitRadius * 0.12);
particles.current = Array.from({ length: particleCount }, () => ({
...spawnParticle(),
radius: rand(minR, orbitRadius),
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
}));
}, [particleCount, orbitRadius]);
/* Detect mobile & seed positions */
useEffect(() => {
setMounted(true);
isMobile.current = isMobileDevice();
if (containerRef.current) {
const cx = containerRef.current.clientWidth / 2;
const cy = containerRef.current.clientHeight / 2;
mousePos.current = { x: cx, y: cy };
smoothMouse.current = { x: cx, y: cy };
mobileAnchor.current = { x: cx, y: cy };
mobileTarget.current = {
x: rand(cx * 0.4, cx * 1.6),
y: rand(cy * 0.4, cy * 1.6),
};
}
}, []);
/* Resize canvas to match container */
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const w = container.clientWidth;
const h = container.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
/* Mouse tracking (desktop only) */
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!containerRef.current || isMobile.current) return;
const rect = containerRef.current.getBoundingClientRect();
mousePos.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("mousemove", handleMouseMove, { passive: true });
return () => el.removeEventListener("mousemove", handleMouseMove);
}, [handleMouseMove]);
/* ── GSAP ticker — draw loop ── */
useGSAP(
() => {
if (!mounted) return;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const tick = () => {
const w = container.clientWidth;
const h = container.clientHeight;
frame.current++;
/* Anchor: smooth-follow cursor or drift on mobile */
if (isMobile.current) {
mobileAnchor.current.x +=
(mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed;
mobileAnchor.current.y +=
(mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed;
const dx = mobileTarget.current.x - mobileAnchor.current.x;
const dy = mobileTarget.current.y - mobileAnchor.current.y;
if (Math.sqrt(dx * dx + dy * dy) < 30) {
mobileTarget.current = {
x: rand(w * 0.15, w * 0.85),
y: rand(h * 0.15, h * 0.85),
};
}
smoothMouse.current.x = mobileAnchor.current.x;
smoothMouse.current.y = mobileAnchor.current.y;
} else {
smoothMouse.current.x +=
(mousePos.current.x - smoothMouse.current.x) * followSpeed;
smoothMouse.current.y +=
(mousePos.current.y - smoothMouse.current.y) * followSpeed;
}
const cx = smoothMouse.current.x;
const cy = smoothMouse.current.y;
/* Clear frame */
ctx.clearRect(0, 0, w, h);
/* Draw each particle */
particles.current.forEach((p) => {
p.angle += p.speed;
const wobble =
Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp;
const r = p.radius + wobble;
const x = cx + Math.cos(p.angle) * r;
const y = cy + Math.sin(p.angle) * r;
/* Soft fade near viewport edges */
const edgeFade = Math.max(
0,
Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1),
);
if (edgeFade <= 0) return;
ctx.globalAlpha = edgeFade;
ctx.fillStyle = palette.particles[p.colorIndex];
ctx.beginPath();
ctx.arc(x, y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
};
gsap.ticker.add(tick);
return () => {
gsap.ticker.remove(tick);
};
},
{
scope: containerRef,
dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette],
},
);
/* ── Render ── */
return (
<div
ref={containerRef}
className={className}
style={{
position: "relative",
minHeight: "100vh",
width: "100%",
overflow: "hidden",
transition: "background-color 0.6s ease",
}}
>
<canvas
ref={canvasRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
zIndex: 0,
pointerEvents: "none",
}}
/>
{/* Grain texture */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
zIndex: 1,
opacity: isDark ? 0.05 : 0.03,
pointerEvents: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
backgroundRepeat: "repeat",
backgroundSize: "180px 180px",
}}
/>
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText";
import gsap from 'gsap'
const AnimatedPageTitle = (
{ text, position }: { text: string, position:gsap.Position }
) => {
const el = useRef<HTMLHeadingElement>(null)
const gsapContext = useGsapContext();
useEffect(() => {
console.log("add animated title with:",position)
const split = new SplitText(el.current, { type: "chars" })
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100 }),position)
gsapContext?.addAnimation(gsap.from(split.chars, {
stagger: 0.05, rotate: -90, opacity: 0, x: -10
}),'>')
},[])
return (
<h1 className="text-4xl opacity-0 font-bold text-balance w-full" ref={el}> {text} </h1>
)
}
export default AnimatedPageTitle;

View File

@@ -4,10 +4,18 @@ import { Moon, Sun } from "lucide-react"
import { useEffect } from "react"
type Props = {activeTheme:string|undefined}
const ThemeIcon = (props:Props) => {
if (props.activeTheme == "dark") {
return (<Sun/>)
} else {
return (<Moon/>)
return (
<>
{props.activeTheme && props.activeTheme == 'dark' &&
<Sun/>
}
{props.activeTheme && props.activeTheme == 'light' &&
<Moon/>
}
{!props.activeTheme &&
<Sun/>
}
</>
)
}
export default ThemeIcon;

View File

@@ -8,6 +8,9 @@ import ThemeIcon from "./ThemeIcon"
export function ThemeSwitch() {
const { setTheme, theme } = useTheme()
if (!theme) {
setTheme('dark')
}
const toggleTheme = () => {
setTheme(theme == "dark" ? "light" : "dark")
}

View File

@@ -19,11 +19,9 @@ export default function TopNav() {
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/projects"}> Projects </Link>
</Button>
<Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/chat"}> Chat </Link>
<Link href={"/music"}> Music </Link>
</Button>
</Show>
</div>
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
<AdminWrap>

View File

@@ -1,18 +1,82 @@
'use client'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import { createContext, useContext, type ReactNode } from 'react'
import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger } from 'gsap/all'
import { createContext, useCallback, useContext, useEffect, useRef, type ReactNode } from 'react'
gsap.registerPlugin(useGSAP)
const GsapContext = createContext<typeof globalThis.gsap | null>(null)
gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(SplitText)
const GsapContext = createContext<{
addAnimation: (
animation: gsap.core.TimelineChild,
position: gsap.Position
) => void,
resetTimeline: () => void,
resumeTimeline: () => void,
getScroller: () => Element | Window | null
} | null>(null)
export function useGsapContext() {
return useContext(GsapContext)
}
export default function GsapProvider({children}:{children:ReactNode}) {
export const useTimeLine = (dep:any,all?:boolean) => {
const gsapContext = useGsapContext()
useEffect(() => {
if (dep instanceof Array && all) {
let acc = true;
let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc )
if (allDepsSatisfied) {
gsapContext?.resumeTimeline()
}
} else {
if (dep) {
gsapContext?.resumeTimeline()
}
}
},[dep])
useEffect(() => {
return () => {
gsapContext?.resetTimeline()
}
},[])
}
export default function GsapProvider({ children }: { children: ReactNode }) {
const tl = useRef<gsap.core.Timeline | null>(null)
const scrollerRef = useRef<Element | Window | null>(null)
const getScroller = useCallback(() => {
const cached = scrollerRef.current
if (!cached || (cached instanceof Element && !document.contains(cached))) {
scrollerRef.current = document.querySelector('[data-slot="scroll-area-viewport"]') ?? window
}
return scrollerRef.current
}, [])
useGSAP(() => {
if (!tl.current) {
tl.current = gsap.timeline({ paused: true })
}
return () => { console.log("gsap cleanup") }
})
const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => {
console.log("add animation to:", position, tl.current !== undefined)
tl.current?.add(animation, position);
},[])
const resetTimeline = useCallback(() => {
tl.current?.kill()
tl.current?.revert()
ScrollTrigger.getAll().forEach(st => st.kill())
tl.current = gsap.timeline({paused:true})
},[])
const resumeTimeline = useCallback(() => {
console.log("resuming timeline:",tl.current)
tl.current?.resume()
},[])
return (
<GsapContext.Provider value={gsap}>
<GsapContext.Provider value={{ addAnimation, resetTimeline, resumeTimeline, getScroller }}>
{children}
</GsapContext.Provider>
)

View File

@@ -1,23 +1,10 @@
'use client'
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export default function ThemeProvider({children}:{children: React.ReactNode}) {
const [mounted,setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
})
if (mounted) {
return (
<NextThemesProvider disableTransitionOnChange nonce="test" attribute="class" defaultTheme="dark">
<NextThemesProvider disableTransitionOnChange attribute="class" defaultTheme="dark">
{children}
</NextThemesProvider>
)
} else {
return (
<>
{children}
</>
)
}
}

View File

@@ -1,86 +0,0 @@
'use server'
import { clerkClient } from '@clerk/nextjs/server'
import { google } from 'googleapis'
import { env } from '~/env'
export async function scheduleMeeting({
title,
description,
dateTime,
durationMinutes,
attendeeEmail,
attendeeName,
userId,
}: {
title: string
description: string
dateTime: string
durationMinutes: number
attendeeEmail?: string
attendeeName?: string
userId: string
}) {
try {
const clerk = await clerkClient()
// Get admin's Google OAuth token to create the event on Gregor's calendar
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
env.ADMIN_USER_CLERK_ID,
'oauth_google',
)
const adminToken = adminTokenResponse.data[0]
if (!adminToken?.token) {
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
}
// Try to resolve visitor's Google email for the invite
let visitorEmail: string | undefined = attendeeEmail
if (!visitorEmail) {
try {
const visitorTokenResponse = await clerk.users.getUserOauthAccessToken(userId, 'oauth_google')
if (visitorTokenResponse.data[0]) {
const user = await clerk.users.getUser(userId)
const googleAccount = user.externalAccounts.find((a) => a.provider === 'google')
visitorEmail = googleAccount?.emailAddress ?? undefined
}
} catch {
// Visitor not signed in with Google — no invite
}
}
const oAuth2Client = new google.auth.OAuth2()
oAuth2Client.setCredentials({ access_token: adminToken.token })
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
const startTime = new Date(dateTime)
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
const attendees: { email: string; displayName?: string }[] = []
if (visitorEmail) {
attendees.push({ email: visitorEmail, displayName: attendeeName })
}
const event = await calendar.events.insert({
calendarId: 'primary',
sendUpdates: 'all',
requestBody: {
summary: title,
description,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
attendees,
},
})
return {
success: true,
eventId: event.data.id,
htmlLink: event.data.htmlLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`,
}
} catch (error) {
console.error('Failed to schedule meeting:', error)
return { success: false, error: 'Failed to schedule meeting. Please try again.' }
}
}

View File

@@ -20,6 +20,9 @@ export default async function AdminSideBar() {
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
<Link href={"/admin/project/list"}> Project List </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Music">
<Link href={"/admin/music"}> Manage Music </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link>
</SimpleSidebarGroup>

View File

@@ -14,13 +14,13 @@ import { SelectItem } from '~/components/ui/select';
import {FormMutationContextProvider} from '~/app/_components/Form/Components/MutationProvider';
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
const schemas = entitySchemas('cvCategory')
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const [id, setId] = useState<string | undefined>(params.entity?.id)
const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert),
defaultValues: {
id: params.entity ? params.entity.id : crypto.randomUUID(),
name: params.entity ? params.entity.name : "",
layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
id: params.entity?.id || crypto.randomUUID(),
name: params.entity?.name || "",
layoutPosition: params.entity?.layoutPosition || "col1"
}
})
let path = usePathname()

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from "react";
import { trpc } from "~/app/_trpc/Client";
import { UploadDropzone } from "~/lib/uploadthing";
import { Label } from "~/components/ui/label";
import { FormScaffold } from "~/app/_components/Form/Components";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { RouterOutputs } from "~/server/routers/_app";
import type { IterableElement } from "type-fest";
import { Toaster } from "~/components/ui/sonner";
import { toast } from "sonner";
import { FormMutationContextProvider } from "~/app/_components/Form/Components/MutationProvider";
import { TextInputFormField } from "~/app/_components/Form/Fields";
import { createMusicInputSchema } from "~/lib/trpc/music/schemas";
export default function CreateUpdateMusicForm(props: {
entity?: IterableElement<RouterOutputs['music']['list']>,
className?: string
}) {
const entity = props.entity;
const [id, setId] = useState<string | undefined>(entity?.id)
const utils = trpc.useUtils();
const form = useForm<z.infer<typeof createMusicInputSchema>>({
resolver: zodResolver(createMusicInputSchema),
defaultValues: {
id: entity?.id || crypto.randomUUID(),
title: entity?.title || "",
description: entity?.description || "",
fileUrl: entity?.fileUrl,
fileKey: entity?.fileKey,
fileName: entity?.fileName,
}
})
const createMutation = trpc.music.create.useMutation({
onSuccess: (values) => {
setId(values?.id);
utils.music.list.invalidate();
},
onError: (e) => {
toast(e.message)
}
})
const updateMutation = trpc.music.update.useMutation({
onSuccess: (_) => {
utils.music.list.invalidate();
},
onError: (e) => {
toast(e.message)
}
})
const deleteMutation = trpc.music.delete.useMutation({
onSuccess: (_) => {
utils.music.list.invalidate();
},
onError: (e) => {
toast(e.message)
}
})
function onSubmit(values: z.infer<typeof createMusicInputSchema>) {
id ?
updateMutation.mutate(values) :
createMutation.mutate(values)
}
return (
<>
<Toaster />
<FormMutationContextProvider value={{
createMutation: createMutation,
updateMutation: updateMutation,
deleteMutation: deleteMutation
}}>
<FormScaffold form={form} onSubmit={onSubmit} title='Music' id={id} className={props.className}>
<TextInputFormField control={form.control} name='title' label='Title'/>
<TextInputFormField control={form.control} name='description' label='Description'/>
<div className="flex flex-col gap-1.5">
<Label>Audio File</Label>
<UploadDropzone
endpoint="musicUploader"
config={{mode: 'auto'}}
onUploadError={(e) => {
toast(e.message)
}}
onClientUploadComplete={(res) => {
console.log(res)
if (res[0]) {
form.setValue('fileKey',res[0].serverData.fileKey);
form.setValue('fileName',res[0].serverData.fileName);
form.setValue('title',res[0].serverData.fileName);
form.setValue('description',res[0].serverData.fileName);
form.setValue('fileUrl',res[0].serverData.fileUrl);
}
console.log(form.getValues());
}}
/>
</div>
</FormScaffold>
</FormMutationContextProvider>
</>
);
}

View File

@@ -0,0 +1,26 @@
'use client'
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm";
import { CollapsibleForm } from "~/app/_components/Form/Components";
import { useEffect } from "react";
export default function AdminMusicPage() {
const { data: tracks } = trpc.music.list.useQuery();
useEffect(() => {console.log(tracks)}, [tracks])
return (
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
{tracks && <>
{tracks.map((t) => (
<Card.Card key={t.id}>
<Card.CardContent>
<UploadMusicForm entity={t} className="w-full"/>
</Card.CardContent>
</Card.Card>
))}
</>}
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
</div>
);
}

View File

@@ -1,97 +0,0 @@
import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai'
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { z } from 'zod'
import { eq, and } from 'drizzle-orm'
import { env } from '~/env'
import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
export async function POST(req: Request) {
const { userId } = await auth()
if (!userId) return new Response('Unauthorized', { status: 401 })
const { messages, sessionId } = (await req.json()) as {
messages: UIMessage[]
sessionId: string
}
// Verify this session belongs to the authenticated user
const session = await db
.select()
.from(chatSession)
.where(and(eq(chatSession.id, sessionId), eq(chatSession.userId, userId)))
.limit(1)
.then((r) => r[0])
if (!session) return new Response('Session not found', { status: 404 })
// Save the latest user message
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
const content = lastMessage.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('')
if (content) {
await db.insert(chatMessage).values({ sessionId, role: 'user', content })
}
}
const result = streamText({
model: openai('gpt-4o'),
system: `You are an AI recruiter assistant on Gregor Lohaus's personal portfolio website.
Your role is to help visitors learn about Gregor's background, skills, and experience, and to schedule meetings.
About Gregor:
- Fullstack developer specialising in TypeScript, React, Next.js, and modern web technologies
- Experienced with Drizzle ORM, tRPC, PostgreSQL, server components, and the T3 stack
- Also experienced with React Native, Expo, Java/Spring, gRPC, AWS, and Linux/Debian server administration
- Open to new opportunities and collaboration
Be professional, friendly, and concise. When a visitor wants to schedule a meeting, collect the necessary details (preferred date/time, duration, and optionally their name/email) and use the scheduleMeeting tool.`,
messages: await convertToModelMessages(messages),
tools: {
scheduleMeeting: tool({
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
inputSchema: z.object({
title: z.string().describe('Meeting title'),
description: z.string().describe('Meeting description / agenda'),
dateTime: z
.string()
.describe(
'ISO 8601 datetime for the meeting start, e.g. 2025-04-01T10:00:00',
),
durationMinutes: z
.number()
.int()
.min(15)
.max(120)
.describe('Duration of the meeting in minutes'),
attendeeEmail: z
.string()
.email()
.optional()
.describe('Email of the visitor to invite (if provided)'),
attendeeName: z.string().optional().describe('Name of the visitor'),
}),
execute: async (input) => scheduleMeeting({ ...input, userId }),
}),
},
stopWhen: stepCountIs(5),
onFinish: async ({ text, finishReason }) => {
if (text && finishReason === 'stop') {
await db.insert(chatMessage).values({
sessionId,
role: 'assistant',
content: text,
})
}
},
})
return result.toUIMessageStreamResponse()
}

View File

@@ -0,0 +1,6 @@
import { createRouteHandler } from "uploadthing/next";
import { fileRouter } from "~/server/uploadthing";
export const { GET, POST } = createRouteHandler({
router: fileRouter,
});

View File

@@ -1,162 +0,0 @@
'use client'
import { useRef, useState, useEffect } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button'
import { Textarea } from '~/components/ui/textarea'
import { cn } from '~/lib/utils'
type DBMessage = {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatInterfaceProps {
sessionId: string
initialMessages: DBMessage[]
}
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
return dbMessages.map((m) => ({
id: m.id,
role: m.role,
parts: [{ type: 'text' as const, text: m.content }],
}))
}
export default function ChatInterface({ sessionId, initialMessages }: ChatInterfaceProps) {
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
body: { sessionId },
}),
messages: toUIMessages(initialMessages),
})
const isLoading = status === 'submitted' || status === 'streaming'
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
sendMessage({ text })
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground py-12">
<p className="text-base font-medium mb-1">Hi! I'm Gregor's AI recruiter assistant.</p>
<p className="text-sm">Ask me about his skills and experience, or schedule a meeting!</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={cn('flex', message.role === 'user' ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm space-y-2',
message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
)}
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<p key={i} className="whitespace-pre-wrap">
{part.text}
</p>
)
}
if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as {
type: 'tool-scheduleMeeting'
state: string
input: unknown
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Scheduling meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
{result.success ? (
<span>
{result.message}{' '}
{result.htmlLink && (
<a
href={result.htmlLink}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View event
</a>
)}
</span>
) : (
<span> {result.error}</span>
)}
</div>
)
}
}
return null
})}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2 text-sm">
<span className="animate-pulse">Thinking</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 border-t flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Gregor's experience or schedule a meeting…"
className="resize-none"
rows={2}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
/>
<Button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="self-end"
>
Send
</Button>
</div>
</div>
)
}

View File

@@ -1,50 +0,0 @@
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
import ChatInterface from './_components/ChatInterface'
export default async function ChatPage() {
const { userId } = await auth()
if (!userId) redirect('/')
let session = await db
.select()
.from(chatSession)
.where(eq(chatSession.userId, userId))
.orderBy(desc(chatSession.createdAt))
.limit(1)
.then((r) => r[0])
if (!session) {
const [created] = await db.insert(chatSession).values({ userId }).returning()
session = created
}
if (!session) redirect('/')
const messages = await db
.select()
.from(chatMessage)
.where(eq(chatMessage.sessionId, session.id))
.orderBy(asc(chatMessage.createdAt))
return (
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col">
<div className="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">AI Recruiter</h1>
<p className="text-xs text-muted-foreground">
Chat with Gregor's AI assistant
</p>
</div>
</div>
<div className="flex-1 overflow-hidden">
<ChatInterface sessionId={session.id} initialMessages={messages} />
</div>
</div>
</div>
)
}

View File

@@ -1,17 +1,18 @@
'use client'
import { useGSAP } from "@gsap/react";
import { useGsapContext } from "../_providers/GsapProvicer";
import { useGsapContext,useTimeLine } from "../_providers/GsapProvicer";
import { trpc } from "../_trpc/Client";
import { useRef } from "react";
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar";
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
import CvCategory from "./_components/CvCategory";
import gsap from 'gsap'
export default function CvPage() {
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
const gsap = useGsapContext()
const gsapContext = useGsapContext()
const container = useRef<HTMLDivElement>(null)
enum Direction {
Left = 1,
@@ -31,12 +32,12 @@ export default function CvPage() {
return { y: 100, opacity: 0, duration: 0.5 }
}
}
useTimeLine(col2Categories)
useGSAP(() => {
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
const tl = gsap?.timeline();
let dir = Direction.Left;
items?.forEach(item => {
tl?.from(item, nextGsapConf(dir))
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)),0)
if (dir == Direction.Down) {
dir = Direction.Left
} else {
@@ -47,7 +48,7 @@ export default function CvPage() {
return (
<>
<SidebarProvider ref={container}>
{(sidebarCategories.data?.length ? sidebarCategories.data?.length : 0) > 0 ?
{sidebarCategories.data &&
<>
<SidebarTriggerDisappearsOnMobile />
<Sidebar className="gsapan ">
@@ -61,8 +62,7 @@ export default function CvPage() {
})}
</SidebarContent>
</Sidebar>
</> :
<></>
</>
}
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">

View File

@@ -12,6 +12,7 @@ import ThemeProvider from './_providers/ThemeProvider'
import GsapProvider from "./_providers/GsapProvicer";
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
import { cn } from "~/lib/utils";
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
@@ -28,7 +29,6 @@ const geist = Geist({
variable: "--font-geist-sans",
});
export default async function RootLayout({
children,
modal
@@ -44,11 +44,13 @@ export default async function RootLayout({
</head>
<body className="flex flex-col bg-background text-foreground">
<ThemeProvider>
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
<TopNav />
<main className="absolute lg:top-10 h-screen w-screen">
{children}
</main>
{modal}
</AnimatedBackGroundContainer>
</ThemeProvider>
</body>
</html>

54
src/app/music/page.tsx Normal file
View File

@@ -0,0 +1,54 @@
'use client'
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
import { Spinner } from "~/components/ui/spinner";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area";
export default function MusicPage() {
const { data: tracks, isLoading } = trpc.music.list.useQuery();
useTimeLine(tracks)
return (
<ScrollArea className="w-full h-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
<AnimatedPageTitle position={0} text="Just Some Music I Made" />
<AnimateTextIn position={0.5}>
<div className="flex flex-col lg:flex-row h-fit content-center">
<p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p>
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
<div className="flex flex-row">
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
</div>
</div>
</AnimateTextIn>
{tracks && tracks.map((track, i) => (
<Card.AnimatedCard key={track.id} position={i + 1}>
<Card.CardHeader>
<AnimateTextIn position={i + 1.2} animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn>
</Card.CardHeader>
<Card.CardContent className="flex flex-col gap-3">
{track.description && (
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)}
<audio controls className="w-full player" src={track.fileUrl}>
Your browser does not support the audio element.
</audio>
</Card.CardContent>
</Card.AnimatedCard>
))}
{!isLoading && !tracks?.length &&
<div className="flex justify-center items-center text-muted-foreground">
No music yet.
</div>
}
{isLoading && <div className="w-full h-full items-center flex flex-row content-center gap-4 justify-center">
<Spinner /> Loading Tracks
</div>}
</ScrollArea>
);
}

View File

@@ -1,5 +1,7 @@
import * as React from "react"
import { useGSAP } from "@gsap/react";import * as React from "react"
import { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import gsap from 'gsap'
import { cn } from "~/lib/utils"
function Card({
@@ -12,7 +14,41 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
className
)}
{...props}
/>
)
}
function AnimatedCard({
className,
position = 0,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position }) {
const gsapContext = useGsapContext()
const ref = useRef<HTMLDivElement|null>(null)
useGSAP(() => {
const rect = ref.current?.getBoundingClientRect()
const isInView = rect && rect.top < window.innerHeight
const fromVars = { x: -100, opacity: 0, duration: 0.5 }
if (isInView) {
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
} else {
const scroller = gsapContext?.getScroller()
console.log('scroller:', scroller)
gsap.from(ref.current, { ...fromVars, scrollTrigger: { trigger: ref.current, start: 'top 85%', scroller } })
}
}, { dependencies: [] })
return (
<div
ref={ref}
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
className
)}
{...props}
@@ -100,4 +136,5 @@ export {
CardAction,
CardDescription,
CardContent,
AnimatedCard
}

View File

@@ -0,0 +1,10 @@
import { cn } from "~/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View File

@@ -27,7 +27,7 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(),
ADMIN_USER_CLERK_ID: z.string(),
OPENAI_API_KEY: z.string(),
UPLOADTHING_TOKEN: z.string(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
@@ -65,7 +65,7 @@ export const env = createEnv({
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,

View File

@@ -0,0 +1,18 @@
import z from "zod"
export const createMusicInputSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(100),
description: z.string().optional(),
fileUrl: z.string(),
fileKey: z.string(),
fileName: z.string(),
})
export const updateMusicInputSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(100).optional(),
description: z.string().optional(),
fileUrl: z.string().optional(),
fileKey: z.string().optional(),
fileName: z.string().optional(),
})

5
src/lib/uploadthing.ts Normal file
View File

@@ -0,0 +1,5 @@
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
import type { FileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton<FileRouter>();
export const UploadDropzone = generateUploadDropzone<FileRouter>();

View File

@@ -86,36 +86,20 @@ export const techStack = createTable(
})
)
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
export const chatSession = createTable(
"chat_session",
export const music = createTable(
"music",
(d) => ({
id: d.uuid().primaryKey().defaultRandom(),
userId: d.varchar({ length: 255 }).notNull(),
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
id: d.uuid().primaryKey().notNull(),
title: d.varchar({ length: 100 }).notNull(),
description: d.text(),
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
fileName: d.varchar("file_name", { length: 200 }).notNull(),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull()
.$type<Date>(),
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
})
)
export const chatSessionRelations = relations(chatSession, ({ many }) => ({
messages: many(chatMessage),
}))
export const chatMessage = createTable(
"chat_message",
(d) => ({
id: d.uuid().primaryKey().defaultRandom(),
sessionId: d.uuid('session_id').notNull(),
role: messageRoleEnum().notNull(),
content: d.text().notNull(),
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
})
)
export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
session: one(chatSession, {
fields: [chatMessage.sessionId],
references: [chatSession.id],
}),
}))

View File

@@ -5,6 +5,7 @@ import { projectRouter } from "./project";
import { techStackRouter } from "./techStack";
import { cvCategoryRouter } from "./cvCategory";
import { cvEntryRouter } from "./cvEntry";
import { musicRouter } from "./music";
import { trpcCrudRouterFromDrizzleEntity } from "../lib";
import { cvCategory } from "../dbschema/schema";
@@ -17,6 +18,7 @@ export const trpcRouter = router({
categoryv2: cvCategoryRouter,
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
entryv2: cvEntryRouter,
music: musicRouter,
});
export type TrpcRouter = typeof trpcRouter;

View File

@@ -0,0 +1,48 @@
import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db";
import { music } from "~/server/dbschema/schema";
import { eq } from "drizzle-orm";
import { isAdmin } from "~/app/actions";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
import { utapi } from "../uploadthing";
export const musicRouter = router({
list: publicProcedure.query(async () => {
let res = await db.select().from(music).orderBy(music.createdAt);
console.log(res);
return res;
}),
create: publicProcedure
.input(
createMusicInputSchema
)
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
let res = await db.insert(music).values(input).returning();
return res.at(0);
}),
update: publicProcedure
.input(
updateMusicInputSchema
)
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
const { id, ...data } = input;
return db.update(music).set(data).where(eq(music.id, id)).returning();
}),
delete: publicProcedure
.input(z.object({id:z.string().uuid()}))
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
let ret = res.at(0)
if (ret) {
utapi.deleteFiles(ret.fileKey)
}
return ret;
}),
});

22
src/server/uploadthing.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createUploadthing, type FileRouter as UploadThingFileRouter } from "uploadthing/next";
import { UTApi } from 'uploadthing/server'
import { isAdmin } from "~/app/actions";
const f = createUploadthing();
export const fileRouter = {
musicUploader: f({ audio: { maxFileSize: "64MB", maxFileCount: 1 } })
.middleware(async () => {
const admin = await isAdmin();
if (!admin) throw new Error("Unauthorized");
return {};
})
.onUploadComplete(async ({ file }) => {
console.log(file)
return { fileUrl: file.ufsUrl, fileKey: file.key, fileName: file.name };
}),
} satisfies UploadThingFileRouter ;
export type FileRouter = typeof fileRouter;
export const utapi = new UTApi();