8 Commits

Author SHA1 Message Date
9d3912b271 ai modal 2026-03-10 21:02:16 +01:00
4916a2fc7b Merge branch 'projectpage' 2026-03-10 21:01:36 +01:00
b0fb481cf2 gitignore worktrees 2026-03-10 21:00:39 +01:00
495bd52c5b neon stack item 2026-03-10 20:59:46 +01:00
bf2085fcce project description 2026-03-10 20:57:09 +01:00
61e016d829 project description 2026-03-10 20:35:30 +01:00
e77eda0220 project page 2026-03-10 20:20:47 +01:00
fc4fab9478 fix adminwrap issue 2026-03-10 19:52:00 +01:00
21 changed files with 857 additions and 25 deletions

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ yarn-error.log*
.idea .idea
# clerk configuration (can include secrets) # clerk configuration (can include secrets)
/.clerk/ /.clerk/
.worktrees

View File

@@ -5,6 +5,8 @@
"": { "": {
"name": "gregorlohaus.com", "name": "gregorlohaus.com",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2", "@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16", "@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -40,6 +42,7 @@
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-next-experimental": "^5.91.0", "@tanstack/react-query-next-experimental": "^5.91.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -48,6 +51,7 @@
"@trpc/react-query": "^11.12.0", "@trpc/react-query": "^11.12.0",
"@trpc/server": "^11.12.0", "@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"ai": "^6.0.116",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -57,6 +61,7 @@
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1", "glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@@ -117,6 +122,16 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], "@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=="], "@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=="], "@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=="],
@@ -519,6 +534,8 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
@@ -777,6 +794,8 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
@@ -935,6 +954,8 @@
"@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=="], "@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=="],
"@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=="], "@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=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="],
@@ -961,6 +982,8 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "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": ["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=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -1003,12 +1026,16 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -1021,6 +1048,8 @@
"bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], "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=="], "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=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1207,6 +1236,8 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "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=="], "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=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -1329,6 +1360,10 @@
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], "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=="], "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=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -1361,6 +1396,14 @@
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], "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=="], "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=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -1585,8 +1628,12 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "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-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-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -1601,6 +1648,10 @@
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], "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=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@@ -1903,7 +1954,7 @@
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
@@ -2033,6 +2084,8 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "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=="], "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=="], "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=="],
@@ -2043,6 +2096,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "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=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@@ -2141,6 +2196,8 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "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=="], "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=="], "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
@@ -2157,6 +2214,8 @@
"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=="], "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=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -2237,6 +2296,8 @@
"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=="],
"url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="],
"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=="], "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=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "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-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "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-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
@@ -2571,6 +2632,8 @@
"shadcn/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "shadcn/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"shadcn/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],

View File

@@ -19,6 +19,8 @@
"test": "vitest --typecheck" "test": "vitest --typecheck"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2", "@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16", "@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -54,6 +56,7 @@
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-next-experimental": "^5.91.0", "@tanstack/react-query-next-experimental": "^5.91.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -62,6 +65,7 @@
"@trpc/react-query": "^11.12.0", "@trpc/react-query": "^11.12.0",
"@trpc/server": "^11.12.0", "@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"ai": "^6.0.116",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -71,6 +75,7 @@
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1", "glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",

View File

@@ -0,0 +1,32 @@
'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

@@ -0,0 +1,34 @@
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

@@ -1,7 +1,10 @@
import { isAdmin } from "~/app/actions" // "use client"
// import { isAdmin } from "~/app/actions"
export default async function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) { import { useUser } from "@clerk/nextjs"
if (await isAdmin()) { import { env } from "~/env"
export default function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) {
const user = useUser();
if (user.isSignedIn && user.user.id == env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID) {
return <>{children}</> return <>{children}</>
} }
return (<></>) return (<></>)

View File

@@ -1,6 +1,7 @@
"use client"
import Link from "next/link" import Link from "next/link"
import AdminWrap from "./AdminWrap" import AdminWrap from "./AdminWrap"
import { Show, SignInButton, SignOutButton, SignUpButton, UserButton } from "@clerk/nextjs" import { ClerkLoaded, Show, SignInButton, SignOutButton, SignUpButton, UserButton } from "@clerk/nextjs"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { ThemeSwitch } from "./ThemeSwitch" import { ThemeSwitch } from "./ThemeSwitch"
@@ -18,6 +19,11 @@ export default function TopNav() {
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline"> <Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/projects"}> Projects </Link> <Link href={"/projects"}> Projects </Link>
</Button> </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>
</Button>
</Show>
</div> </div>
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto"> <div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
<AdminWrap> <AdminWrap>
@@ -39,6 +45,7 @@ export default function TopNav() {
</Button> </Button>
</Show> </Show>
<ThemeSwitch /> <ThemeSwitch />
<ClerkLoaded>
<Show when="signed-in"> <Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}> <Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
<div> <div>
@@ -46,6 +53,7 @@ export default function TopNav() {
</div> </div>
</Button> </Button>
</Show> </Show>
</ClerkLoaded>
</div> </div>
</nav> </nav>
</div> </div>

View File

@@ -0,0 +1,86 @@
'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

@@ -47,7 +47,7 @@ export default function CvPage() {
entires[0].filter((e) => { return e.categoryId == cat.id }).length > 0 ? ( entires[0].filter((e) => { return e.categoryId == cat.id }).length > 0 ? (
<> <>
{entires[0].filter((e) => { return e.categoryId == cat.id }).map((entry) => ( {entires[0].filter((e) => { return e.categoryId == cat.id }).map((entry) => (
<CollapsibleForm entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" /> <CollapsibleForm key={entry.id} entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" />
))} ))}
</> </>
) : (<></>) ) : (<></>)

View File

@@ -8,19 +8,23 @@ import type { IterableElement } from 'type-fest'
import { entitySchemas, makeOnSuccess } from "~/lib/utils"; import { entitySchemas, makeOnSuccess } from "~/lib/utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RouterOutputs } from '~/server/routers/_app'; import type { RouterOutputs } from '~/server/routers/_app';
import { SelectFormField, TextInputFormField } from '~/app/_components/Form/Fields' import { SelectFormField, TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
import { FormScaffold } from '~/app/_components/Form/Components'; import { FormScaffold } from '~/app/_components/Form/Components';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { makeUseRelationShipWithNameIndex } from '~/lib/hooks'; import { makeUseRelationShipWithNameIndex } from '~/lib/hooks';
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'; import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider';
export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) { export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) {
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined) const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const { theme } = useTheme()
const schemas = entitySchemas('project') const schemas = entitySchemas('project')
const { data: stacks, id: stackId, name: stackName, success: stacksSuccess, error: stackError } = makeUseRelationShipWithNameIndex('stackItems')(trpc.techStack.select.useQuery({}), id, (items) => { return items ? items.join('-') : "" }) const { data: stacks, id: stackId, name: stackName, success: stacksSuccess, error: stackError } = makeUseRelationShipWithNameIndex('stackItems')(trpc.techStack.select.useQuery({}), id, (items) => { return items ? items.join('-') : "" })
const form = useForm<z.infer<typeof schemas.insert>>({ const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert), resolver: zodResolver(schemas.insert),
defaultValues: { defaultValues: {
id: id ? id : crypto.randomUUID(), id: id ? id : crypto.randomUUID(),
title: params.entity ? params.entity.title : "",
description: params.entity ? params.entity.description : "",
stackId: params.entity ? params.entity.stackId : stacksSuccess ? stacks?.at(0)?.id : "", stackId: params.entity ? params.entity.stackId : stacksSuccess ? stacks?.at(0)?.id : "",
releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased", releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased",
releaseLink: params.entity ? params.entity.releaseLink : "", releaseLink: params.entity ? params.entity.releaseLink : "",
@@ -64,6 +68,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
} }
</SelectFormField> </SelectFormField>
<TextInputFormField control={form.control} name='title' label='Title' /> <TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} />
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' > <SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
<SelectItem value="open"> open </SelectItem> <SelectItem value="open"> open </SelectItem>
<SelectItem value="closed"> closed </SelectItem> <SelectItem value="closed"> closed </SelectItem>

View File

@@ -38,7 +38,7 @@ export default function ProjectList() {
techStacks[0].filter((e) => { return e.id == project.stackId }).length > 0 ? ( techStacks[0].filter((e) => { return e.id == project.stackId }).length > 0 ? (
<> <>
{techStacks[0].filter((e) => { return e.id == project.stackId }).map((stack) => ( {techStacks[0].filter((e) => { return e.id == project.stackId }).map((stack) => (
<CollapsibleForm entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/> <CollapsibleForm key={stack.id} entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/>
))} ))}
</> </>
) : (<></>) ) : (<></>)

97
src/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,97 @@
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,162 @@
'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>
)
}

50
src/app/chat/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
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,12 +1,93 @@
'use client' 'use client'
import { usePathname } from "next/navigation" import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { StackBadge } from "~/components/StackBadge";
import Markdown from "react-markdown";
export default function Page() { export default function ProjectsPage() {
const pathName = usePathname() const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
if (isLoading) {
return ( return (
<div> <div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
{pathName} Loading...
</div> </div>
) );
}
if (!projects?.length) {
return (
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
No projects yet.
</div>
);
}
return (
<div className="w-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
{projects.map((project) => (
<Card.Card key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<Card.CardTitle>{project.title}</Card.CardTitle>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<Markdown>{project.description}</Markdown>
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="flex gap-3 flex-wrap">
{project.sourceLink && (
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
>
Source
</a>
)}
{project.releaseLink && (
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
>
Live
</a>
)}
</div>
)}
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item) => (
<StackBadge key={item} item={item} />
))}
</div>
)}
</Card.CardContent>
)}
</Card.Card>
))}
</div>
);
} }

View File

@@ -0,0 +1,156 @@
import { Badge } from "~/components/ui/badge";
interface SvglIcon {
light: string;
dark: string;
}
const STACK_META: Record<string, { label: string; icon?: SvglIcon }> = {
drizzle: {
label: "Drizzle ORM",
icon: {
light: "https://svgl.app/library/drizzle-orm_light.svg",
dark: "https://svgl.app/library/drizzle-orm_dark.svg",
},
},
postgres: {
label: "PostgreSQL",
icon: {
light: "https://svgl.app/library/postgresql.svg",
dark: "https://svgl.app/library/postgresql.svg",
},
},
nextjs: {
label: "Next.js",
icon: {
light: "https://svgl.app/library/nextjs_icon_dark.svg",
dark: "https://svgl.app/library/nextjs_icon_dark.svg",
},
},
react: {
label: "React",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
servercomponents: { label: "Server Components" },
php: {
label: "PHP",
icon: {
light: "https://svgl.app/library/php.svg",
dark: "https://svgl.app/library/php_dark.svg",
},
},
laravel: {
label: "Laravel",
icon: {
light: "https://svgl.app/library/laravel.svg",
dark: "https://svgl.app/library/laravel.svg",
},
},
reactnative: {
label: "React Native",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
"react-native": {
label: "React Native",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
expo: {
label: "Expo",
icon: {
light: "https://svgl.app/library/expo.svg",
dark: "https://svgl.app/library/expo.svg",
},
},
mysql: {
label: "MySQL",
icon: {
light: "https://svgl.app/library/mysql-icon-light.svg",
dark: "https://svgl.app/library/mysql-icon-dark.svg",
},
},
nginx: {
label: "Nginx",
icon: {
light: "https://svgl.app/library/nginx.svg",
dark: "https://svgl.app/library/nginx.svg",
},
},
protobuf: { label: "Protobuf" },
grpc: { label: "gRPC" },
java: {
label: "Java",
icon: {
light: "https://svgl.app/library/java.svg",
dark: "https://svgl.app/library/java.svg",
},
},
graalvm: { label: "GraalVM" },
spring: {
label: "Spring",
icon: {
light: "https://svgl.app/library/spring.svg",
dark: "https://svgl.app/library/spring.svg",
},
},
aws: {
label: "AWS",
icon: {
light: "https://svgl.app/library/aws_light.svg",
dark: "https://svgl.app/library/aws_dark.svg",
},
},
s3: { label: "Amazon S3" },
linux: {
label: "Linux",
icon: {
light: "https://svgl.app/library/linux.svg",
dark: "https://svgl.app/library/linux.svg",
},
},
debian: { label: "Debian" },
htmx: { label: "HTMX" },
neon: {
label: "Neon",
icon: {
light: "https://svgl.app/library/neon.svg",
dark: "https://svgl.app/library/neon.svg",
},
},
};
export function StackBadge({ item }: { item: string }) {
const meta = STACK_META[item] ?? { label: item };
return (
<Badge variant="outline">
{meta.icon && (
<>
<img
src={meta.icon.light}
alt=""
width={12}
height={12}
className="dark:hidden shrink-0"
/>
<img
src={meta.icon.dark}
alt=""
width={12}
height={12}
className="hidden dark:block shrink-0"
/>
</>
)}
{meta.label}
</Badge>
);
}

View File

@@ -27,6 +27,7 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(), CLERK_SECRET_KEY: z.string(),
ADMIN_USER_CLERK_ID: z.string(), ADMIN_USER_CLERK_ID: z.string(),
OPENAI_API_KEY: z.string(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
@@ -38,6 +39,7 @@ export const env = createEnv({
* `NEXT_PUBLIC_`. * `NEXT_PUBLIC_`.
*/ */
client: { client: {
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: z.string(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string() NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string()
// NEXT_PUBLIC_CLIENTVAR: z.string(), // NEXT_PUBLIC_CLIENTVAR: z.string(),
}, },
@@ -63,6 +65,8 @@ export const env = createEnv({
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL, POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL, POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID, ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY, CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,

View File

@@ -4,6 +4,7 @@ import { env } from "~/env";
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)']) const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
export default clerkMiddleware(async (auth,req) => { export default clerkMiddleware(async (auth,req) => {
if (isTenantAdminRoute(req)) { if (isTenantAdminRoute(req)) {
console.log("running clerk middleware");
let userid = (await auth()).userId let userid = (await auth()).userId
if (userid != env.ADMIN_USER_CLERK_ID) { if (userid != env.ADMIN_USER_CLERK_ID) {
await auth.protect() await auth.protect()

View File

@@ -55,13 +55,14 @@ export const cvEntryRelations = relations(cvEntry, ({one}) => ({
export const sourceTypeEnum = pgEnum('source_type',['open','closed']) export const sourceTypeEnum = pgEnum('source_type',['open','closed'])
export const releaseStatus = pgEnum('release_status',['released','unreleased']) export const releaseStatus = pgEnum('release_status',['released','unreleased'])
export const stackItemEnum = pgEnum('stack_item',['drizzle','postgres','nextjs','react','servercomponents','php','laravel','reactnative','expo','mysql','nginx','protobuf','grpc']) export const stackItemEnum = pgEnum('stack_item',['drizzle','postgres','nextjs','react','servercomponents','php','laravel','reactnative','expo','mysql','nginx','protobuf','grpc','java','graalvm','spring','aws','s3','react-native','linux','debian','htmx','neon'])
export const project = createTable( export const project = createTable(
"project", "project",
(d) => ({ (d) => ({
id: d.uuid().primaryKey().notNull(), id: d.uuid().primaryKey().notNull(),
title: d.varchar({length: 50}).notNull(), title: d.varchar({length: 50}).notNull(),
description: d.text(),
sourceType: sourceTypeEnum(), sourceType: sourceTypeEnum(),
sourceLink: d.varchar({length: 200}), sourceLink: d.varchar({length: 200}),
releaseStatus: releaseStatus(), releaseStatus: releaseStatus(),
@@ -84,3 +85,37 @@ export const techStack = createTable(
stackItems: stackItemEnum().array() stackItems: stackItemEnum().array()
}) })
) )
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
export const chatSession = createTable(
"chat_session",
(d) => ({
id: d.uuid().primaryKey().defaultRandom(),
userId: d.varchar({ length: 255 }).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

@@ -1,4 +1,12 @@
import { router } from "~/server/trpc"; import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db";
export const projectRouter = router({ export const projectRouter = router({
listWithStack: publicProcedure.query(async () => {
return db.query.project.findMany({
with: {
techStack: true,
},
});
}),
}); });

View File

@@ -1,7 +1,8 @@
import type { Config } from "tailwindcss" import type { Config } from "tailwindcss"
const config = { const config = {
plugins: [ plugins: [
require("tailwindcss-motion") require("tailwindcss-motion"),
require("@tailwindcss/typography")
] ]
} satisfies Config } satisfies Config