diff --git a/bun.lock b/bun.lock index 9fa38fb..59bd064 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@tanstack/react-router-ssr-query": "^1.132.31", "@tanstack/react-start": "^1.132.32", "@tanstack/router-plugin": "^1.132.31", + "@vercel/og": "^0.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -50,7 +51,7 @@ "lucide-react": "^0.544.0", "motion": "^12.23.22", "next-themes": "^0.4.6", - "node-appwrite": "^20.2.1", + "node-appwrite": "^21.1.0", "react": "^19.1.1", "react-day-picker": "^9.11.0", "react-dom": "^19.1.1", @@ -453,6 +454,8 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.9.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog=="], + "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.0", "", {}, "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.42", "", { "os": "android", "cpu": "arm64" }, "sha512-W5ZKF3TP3bOWuBfotAGp+UGjxOkGV7jRmIRbBA7NFjggx7Oi6vOmGDqpHEIX7kDCiry1cnIsWQaxNvWbMdkvzQ=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.42", "", { "os": "darwin", "cpu": "arm64" }, "sha512-abw/wtgJA8OCgaTlL+xJxnN/Z01BwV1rfzIp5Hh9x+IIO6xOBfPsQ0nzi0+rWx3TyZ9FZXyC7bbC+5NpQ9EaXQ=="], @@ -543,6 +546,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.3", "", { "os": "win32", "cpu": "x64" }, "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA=="], + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], + "@sindresorhus/is": ["@sindresorhus/is@7.1.0", "", {}, "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], @@ -773,6 +778,8 @@ "@vercel/nft": ["@vercel/nft@0.30.2", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^10.4.5", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-pquXF3XZFg/T3TBor08rUhIGgOhdSilbn7WQLVP/aVSSO+25Rs4H/m3nxNDQ2x3znX7Z3yYjryN8xaLwypcwQg=="], + "@vercel/og": ["@vercel/og@0.8.6", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.0", "satori": "0.16.0" } }, "sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.4", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.38", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -837,7 +844,7 @@ "bare-events": ["bare-events@2.7.0", "", {}, "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA=="], @@ -867,6 +874,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001745", "", {}, "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], @@ -935,8 +944,18 @@ "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], + + "css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="], + + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-gradient-parser": ["css-gradient-parser@0.0.16", "", {}, "sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], @@ -1033,6 +1052,8 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], @@ -1119,6 +1140,8 @@ "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], @@ -1181,6 +1204,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], @@ -1311,6 +1336,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "listhen": ["listhen@1.9.0", "", { "dependencies": { "@parcel/watcher": "^2.4.1", "@parcel/watcher-wasm": "^2.4.1", "citty": "^0.1.6", "clipboardy": "^4.0.0", "consola": "^3.2.3", "crossws": ">=0.2.0 <0.4.0", "defu": "^6.1.4", "get-port-please": "^3.1.2", "h3": "^1.12.0", "http-shutdown": "^1.2.2", "jiti": "^2.1.2", "mlly": "^1.7.1", "node-forge": "^1.3.1", "pathe": "^1.1.2", "std-env": "^3.7.0", "ufo": "^1.5.4", "untun": "^0.1.3", "uqr": "^0.1.2" }, "bin": { "listen": "bin/listhen.mjs", "listhen": "bin/listhen.mjs" } }, "sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg=="], "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], @@ -1379,7 +1406,7 @@ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-appwrite": ["node-appwrite@20.2.1", "", { "dependencies": { "node-fetch-native-with-agent": "1.7.2" } }, "sha512-RweIh+3RHjprsxhWaJzcQr/UDMBMsZCma50TIJ9t3onVgs5jAT9aqFnsMlaaC9QZn1sXpPUQV90W6uvtm64DnQ=="], + "node-appwrite": ["node-appwrite@21.1.0", "", { "dependencies": { "node-fetch-native-with-agent": "1.7.2" } }, "sha512-HRK5BzN19vgvaH/EeNsigK24t4ngJ1AoiltK5JtahxP6uyMRztzkD8cXP+z9jj/xOjz7ySfQ9YypNyhNr6zVkA=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -1425,8 +1452,12 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1459,6 +1490,8 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], @@ -1553,6 +1586,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "satori": ["satori@0.16.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -1617,6 +1652,8 @@ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1651,6 +1688,8 @@ "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -1713,6 +1752,8 @@ "unenv": ["unenv@2.0.0-rc.21", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.6.1" } }, "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A=="], + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unimport": ["unimport@5.4.0", "", { "dependencies": { "acorn": "^8.15.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.19", "mlly": "^1.8.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "pkg-types": "^2.3.0", "scule": "^1.3.0", "strip-literal": "^3.0.0", "tinyglobby": "^0.2.15", "unplugin": "^2.3.10", "unplugin-utils": "^0.3.0" } }, "sha512-g/OLFZR2mEfqbC6NC9b2225eCJGvufxq34mj6kM3OmI5gdSL0qyqtnv+9qmsGpAmnzSl6x0IWZj4W+8j2hLkMA=="], @@ -1807,6 +1848,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.11", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-sQi6PERyO/mT8w564ojOVeAlYTtVQmC2GaktQAf+IdI75/GKIggosBuvyVXvEV+FATAT6RbLdIjFoiIId4ozoQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -1917,6 +1960,8 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bun-types/@types/node": ["@types/node@22.18.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3E97nlWEVp2V6J7aMkR8eOnw/w0pArPwf/5/W0865f+xzBoGL/ZuHkTAKAGN7cOWNwd+sG+hZOqj+fjzeHS75g=="], "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], diff --git a/package.json b/package.json index d46eb40..f9511b3 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@tanstack/react-router-ssr-query": "^1.132.31", "@tanstack/react-start": "^1.132.32", "@tanstack/router-plugin": "^1.132.31", + "@vercel/og": "^0.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -61,7 +62,7 @@ "lucide-react": "^0.544.0", "motion": "^12.23.22", "next-themes": "^0.4.6", - "node-appwrite": "^20.2.1", + "node-appwrite": "^21.1.0", "react": "^19.1.1", "react-day-picker": "^9.11.0", "react-dom": "^19.1.1", diff --git a/public/default-og-image.png b/public/default-og-image.png new file mode 100644 index 0000000..11c2138 Binary files /dev/null and b/public/default-og-image.png differ diff --git a/src/lib/og-config.ts b/src/lib/og-config.ts new file mode 100644 index 0000000..1b8e1c7 --- /dev/null +++ b/src/lib/og-config.ts @@ -0,0 +1,103 @@ +export type OGImageConfig = { + isCustom: boolean + title?: string + description?: string + image?: string + icon?: string + width?: number + height?: number + backgroundColor?: string + titleColor?: string + descriptionColor?: string + fontSize?: { + title: number + description: number + } + borderRadius?: number +} + +export type OGMetaTags = { + title: string + description: string + image: string + url: string + type?: 'website' | 'article' | 'profile' + twitterHandle?: string +} + +export const defaultCustomOGConfig: OGImageConfig = { + isCustom: true, + title: 'Imagine App', + description: 'Build something real', + width: 1200, + height: 630, + backgroundColor: '#ffffff', + titleColor: '#000000', + descriptionColor: '#666666', + fontSize: { + title: 60, + description: 30, + }, + borderRadius: 12, +} + +export function generateOGImageUrl( + config: Partial, + baseUrl: string, +): string { + const defaultOGUrl = `${baseUrl.replace(/\/$/, '')}/og` + + if (!config.isCustom) { + return defaultOGUrl + } + + const merged = { ...defaultCustomOGConfig, ...config } + + const params = new URLSearchParams({ + ...(merged.title && { title: merged.title }), + ...(merged.description && { description: merged.description }), + ...(merged.image && { image: merged.image }), + ...(merged.backgroundColor && { bgColor: merged.backgroundColor }), + ...(merged.titleColor && { titleColor: merged.titleColor }), + ...(merged.descriptionColor && { + descriptionColor: merged.descriptionColor, + }), + ...(merged.fontSize?.title && { + titleSize: merged.fontSize.title.toString(), + }), + ...(merged.fontSize?.description && { + descSize: merged.fontSize.description.toString(), + }), + }) + + return `${defaultOGUrl}?${params.toString()}` +} + +export function createOGMetaTags(config: OGMetaTags) { + const { + title, + description, + image, + url, + type = 'website', + twitterHandle, + } = config + + return { + meta: [ + { property: 'og:title', content: title }, + { property: 'og:description', content: description }, + { property: 'og:image', content: image }, + { property: 'og:url', content: url }, + { property: 'og:type', content: type }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:image', content: image }, + ...(twitterHandle + ? [{ name: 'twitter:creator', content: twitterHandle }] + : []), + { name: 'description', content: description }, + ], + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5682ab0..a1e6150 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as AuthSignOutRouteImport } from './routes/_auth/sign-out' import { Route as AuthSignInRouteImport } from './routes/_auth/sign-in' import { Route as AuthResetPasswordRouteImport } from './routes/_auth/reset-password' import { Route as AuthForgotPasswordRouteImport } from './routes/_auth/forgot-password' +import { Route as ApiOgRouteImport } from './routes/_api/og' import { Route as ApiHelloRouteImport } from './routes/_api/hello' const PublicRoute = PublicRouteImport.update({ @@ -69,6 +70,11 @@ const AuthForgotPasswordRoute = AuthForgotPasswordRouteImport.update({ path: '/forgot-password', getParentRoute: () => AuthRoute, } as any) +const ApiOgRoute = ApiOgRouteImport.update({ + id: '/_api/og', + path: '/og', + getParentRoute: () => rootRouteImport, +} as any) const ApiHelloRoute = ApiHelloRouteImport.update({ id: '/_api/hello', path: '/hello', @@ -77,6 +83,7 @@ const ApiHelloRoute = ApiHelloRouteImport.update({ export interface FileRoutesByFullPath { '/hello': typeof ApiHelloRoute + '/og': typeof ApiOgRoute '/forgot-password': typeof AuthForgotPasswordRoute '/reset-password': typeof AuthResetPasswordRoute '/sign-in': typeof AuthSignInRoute @@ -87,6 +94,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/hello': typeof ApiHelloRoute + '/og': typeof ApiOgRoute '/forgot-password': typeof AuthForgotPasswordRoute '/reset-password': typeof AuthResetPasswordRoute '/sign-in': typeof AuthSignInRoute @@ -101,6 +109,7 @@ export interface FileRoutesById { '/_protected': typeof ProtectedRouteWithChildren '/_public': typeof PublicRouteWithChildren '/_api/hello': typeof ApiHelloRoute + '/_api/og': typeof ApiOgRoute '/_auth/forgot-password': typeof AuthForgotPasswordRoute '/_auth/reset-password': typeof AuthResetPasswordRoute '/_auth/sign-in': typeof AuthSignInRoute @@ -113,6 +122,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/hello' + | '/og' | '/forgot-password' | '/reset-password' | '/sign-in' @@ -123,6 +133,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/hello' + | '/og' | '/forgot-password' | '/reset-password' | '/sign-in' @@ -136,6 +147,7 @@ export interface FileRouteTypes { | '/_protected' | '/_public' | '/_api/hello' + | '/_api/og' | '/_auth/forgot-password' | '/_auth/reset-password' | '/_auth/sign-in' @@ -150,6 +162,7 @@ export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren PublicRoute: typeof PublicRouteWithChildren ApiHelloRoute: typeof ApiHelloRoute + ApiOgRoute: typeof ApiOgRoute } declare module '@tanstack/react-router' { @@ -224,6 +237,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthForgotPasswordRouteImport parentRoute: typeof AuthRoute } + '/_api/og': { + id: '/_api/og' + path: '/og' + fullPath: '/og' + preLoaderRoute: typeof ApiOgRouteImport + parentRoute: typeof rootRouteImport + } '/_api/hello': { id: '/_api/hello' path: '/hello' @@ -280,6 +300,7 @@ const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, PublicRoute: PublicRouteWithChildren, ApiHelloRoute: ApiHelloRoute, + ApiOgRoute: ApiOgRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 06a9da4..0e3a570 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -9,6 +9,13 @@ import type { QueryClient } from '@tanstack/react-query' import { Toaster } from '@/components/ui/sonner' import { ThemeProvider } from 'next-themes' import { authMiddleware } from '@/server/functions/auth' +import { getBaseUrl } from '@/server/functions/request' +import { + createOGMetaTags, + generateOGImageUrl, + OGImageConfig, + OGMetaTags, +} from '@/lib/og-config' interface MyRouterContext { queryClient: QueryClient @@ -29,32 +36,57 @@ if (import.meta.env.VITE_INSTRUMENTATION_SCRIPT_SRC) { export const Route = createRootRouteWithContext()({ loader: async () => { const { currentUser } = await authMiddleware() + const baseUrl = await getBaseUrl() return { currentUser, + baseUrl, + } + }, + head: ({ loaderData }) => { + const baseUrl = + typeof window !== 'undefined' + ? window.location.origin + : (loaderData?.baseUrl ?? 'https://imagine.dev') + + const config: OGImageConfig = { + isCustom: false, + } + + const ogImageUrl = generateOGImageUrl(config, baseUrl) + + const metadata: OGMetaTags = { + title: 'Imagine App', + description: 'Build something real', + image: ogImageUrl, + url: typeof window !== 'undefined' ? window.location.href : baseUrl, + } + + const ogTags = createOGMetaTags(metadata) + + return { + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'Imagine App', + }, + ...ogTags.meta, + ], + links: [ + { + rel: 'stylesheet', + href: appCss, + }, + ], + scripts: [...scripts], } }, - head: () => ({ - meta: [ - { - charSet: 'utf-8', - }, - { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }, - { - title: 'Imagine App', - }, - ], - links: [ - { - rel: 'stylesheet', - href: appCss, - }, - ], - scripts: [...scripts], - }), shellComponent: RootDocument, }) diff --git a/src/routes/_api/og.tsx b/src/routes/_api/og.tsx new file mode 100644 index 0000000..03091e1 --- /dev/null +++ b/src/routes/_api/og.tsx @@ -0,0 +1,233 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ImageResponse } from '@vercel/og' +import { getBaseUrl } from '@/server/functions/request' +import { defaultCustomOGConfig } from '@/lib/og-config' +import { getScreenshot } from '@/server/lib/avatars' + +async function loadGoogleFont(font: string, text: string) { + const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}` + const css = await (await fetch(url)).text() + const resource = css.match( + /src: url\((.+?)\) format\('(woff2|opentype|truetype)'\)/, + ) + + if (resource) { + const response = await fetch(resource[1]) + if (response.status == 200) { + return await response.arrayBuffer() + } + } + + throw new Error('failed to load font data') +} + +export const Route = createFileRoute('/_api/og')({ + server: { + handlers: { + GET: async ({ request }) => { + const { searchParams } = new URL(request.url) + + const baseUrl = await getBaseUrl() + + if (!searchParams.toString()) { + const assetUrl = new URL('/default-og-image.png', baseUrl) + const assetResponse = await fetch(assetUrl) + + if (!assetResponse.ok || !assetResponse.body) { + return new Response('Default OG image not found', { + status: 500, + }) + } + + let screenshotArrayBuffer: ArrayBuffer | null = null + try { + screenshotArrayBuffer = await getScreenshot( + baseUrl, + 870, + 543, + 1, // seconds to wait before taking screenshot + ) + } catch (error) { + console.error('Failed to generate screenshot:', error) + // Continue without screenshot overlay + } + + // Create a composite image with screenshot overlay + return new ImageResponse( + ( +
+ {/* Background image */} + + {/* Screenshot overlay - only render if screenshot was successful */} + {screenshotArrayBuffer && ( + + )} +
+ ), + { + width: 1200, + height: 630, + }, + ) as unknown as Response + } + + const title = searchParams.get('title') || defaultCustomOGConfig.title + const description = + searchParams.get('description') || defaultCustomOGConfig.description + const bgColor = + searchParams.get('bgColor') || defaultCustomOGConfig.backgroundColor + const titleColor = + searchParams.get('titleColor') || defaultCustomOGConfig.titleColor + const descriptionColor = + searchParams.get('descriptionColor') || + defaultCustomOGConfig.descriptionColor + const titleSize = parseInt( + searchParams.get('titleSize') || + defaultCustomOGConfig.fontSize?.title.toString() || + '60', + ) + const descSize = parseInt( + searchParams.get('descSize') || + defaultCustomOGConfig.fontSize?.description.toString() || + '30', + ) + const width = defaultCustomOGConfig.width || 1200 + const height = defaultCustomOGConfig.height || 630 + + const text = `${title}${description ? ` ${description}` : ''}` + const fontData = await loadGoogleFont('Inter', text) + + return new ImageResponse( + ( +
+ {/* Content section */} +
+
+ {/* Logo */} +
+ + + + + + + + + + +
+ +

+ {title} +

+ + {description && ( +

+ {description} +

+ )} +
+
+
+ ), + { + width, + height, + fonts: [ + { + name: 'Inter', + data: fontData, + style: 'normal', + }, + ], + }, + ) as unknown as Response + }, + }, + }, +}) diff --git a/src/server/functions/request.ts b/src/server/functions/request.ts new file mode 100644 index 0000000..6221131 --- /dev/null +++ b/src/server/functions/request.ts @@ -0,0 +1,25 @@ +import { createServerFn } from '@tanstack/react-start' +import { getRequestHeader } from '@tanstack/react-start/server' + +export const getBaseUrl = createServerFn({ method: 'GET' }).handler(() => { + const origin = getRequestHeader('origin') + const host = getRequestHeader('host') + + // Prefer origin header, fall back to constructing from host + if (origin) { + return origin + } + + if (host) { + // Determine protocol - default to https in production + const protocol = + process.env.NODE_ENV === 'production' || + getRequestHeader('x-forwarded-proto') === 'https' + ? 'https' + : 'http' + return `${protocol}://${host}` + } + + // Final fallback + return `https://imagine-${import.meta.env.VITE_APPWRITE_PROJECT_ID}.appwrite.network` +}) diff --git a/src/server/lib/appwrite.ts b/src/server/lib/appwrite.ts index 03df65d..363da4d 100644 --- a/src/server/lib/appwrite.ts +++ b/src/server/lib/appwrite.ts @@ -3,7 +3,7 @@ * These should only be imported in server-side actions (SSR, functions). */ -import { Client, Account, Storage, Users } from 'node-appwrite' +import { Client, Account, Storage, Avatars, Users } from 'node-appwrite' const getAppwriteClientCredentials = () => { const endpoint = process.env.APPWRITE_ENDPOINT @@ -44,6 +44,7 @@ export async function createSessionClient(session: string) { export function createAdminClient(): { client: Client account: Account + avatars: Avatars } { const { endpoint, projectId, apiKey } = getAppwriteClientCredentials() const client = new Client() @@ -54,5 +55,6 @@ export function createAdminClient(): { return { client: client, account: new Account(client), + avatars: new Avatars(client), } } diff --git a/src/server/lib/avatars.ts b/src/server/lib/avatars.ts new file mode 100644 index 0000000..4e15d77 --- /dev/null +++ b/src/server/lib/avatars.ts @@ -0,0 +1,22 @@ +import { createAdminClient } from './appwrite' + +export async function getScreenshot( + url: string, + width?: number, + height?: number, + sleep?: number, +) { + try { + const { avatars } = createAdminClient() + + return await avatars.getScreenshot({ + url, + width, + height, + sleep, + }) + } catch (error) { + console.error('Error getting screenshot:', error) + throw error + } +}