diff --git a/next.config.mjs b/next.config.mjs index 00db339..31e3c19 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -23,6 +23,7 @@ const nextConfig = { remotePatterns: [ { protocol: 'https', hostname: 'velog.velcdn.com', pathname: '**' }, { protocol: 'https', hostname: 'images.velog.io', pathname: '**' }, + { protocol: 'http', hostname: 'localhost', pathname: '**' }, ], }, }; diff --git a/package.json b/package.json index 9eefd01..08bd5a7 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@sentry/nextjs": "^8.47.0", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.69.0", + "@vercel/og": "^0.8.6", "chart.js": "^4.4.7", "chartjs-plugin-datalabels": "^2.2.0", "holy-loader": "^2.3.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d24524..73ad964 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tanstack/react-query': specifier: ^5.69.0 version: 5.69.0(react@18.3.1) + '@vercel/og': + specifier: ^0.8.6 + version: 0.8.6 chart.js: specifier: ^4.4.7 version: 4.4.7 @@ -1430,6 +1433,10 @@ packages: '@prisma/instrumentation@5.22.0': resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==} + '@resvg/resvg-wasm@2.4.0': + resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==} + engines: {node: '>= 10'} + '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -1628,6 +1635,11 @@ packages: peerDependencies: webpack: '>=4.40.0' + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -2057,6 +2069,10 @@ packages: '@ungap/structured-clone@1.2.1': resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==} + '@vercel/og@0.8.6': + resolution: {integrity: sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ==} + engines: {node: '>=16'} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2361,6 +2377,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2446,6 +2466,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001690: resolution: {integrity: sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==} @@ -2626,12 +2649,29 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-gradient-parser@0.0.16: + resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==} + engines: {node: '>=16'} + css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@1.1.3: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} @@ -2851,6 +2891,10 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -2923,6 +2967,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -3207,6 +3254,9 @@ packages: picomatch: optional: true + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -3415,6 +3465,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -3959,6 +4013,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -4285,10 +4342,16 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -4704,6 +4767,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satori@0.16.0: + resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==} + engines: {node: '>=16'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -4872,6 +4939,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -5033,6 +5103,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -5195,6 +5268,9 @@ packages: resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -5401,6 +5477,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zustand@5.0.3: resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'} @@ -6965,6 +7044,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@resvg/resvg-wasm@2.4.0': {} + '@rollup/plugin-commonjs@28.0.1(rollup@3.29.5)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@3.29.5) @@ -7236,6 +7317,11 @@ snapshots: - encoding - supports-color + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -7784,6 +7870,11 @@ snapshots: '@ungap/structured-clone@1.2.1': {} + '@vercel/og@0.8.6': + dependencies: + '@resvg/resvg-wasm': 2.4.0 + satori: 0.16.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -8160,6 +8251,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} bcrypt-pbkdf@1.0.2: @@ -8240,6 +8333,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-lite@1.0.30001690: {} caseless@0.12.0: {} @@ -8409,6 +8504,14 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-background-parser@0.1.0: {} + + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.16: {} + css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -8425,6 +8528,12 @@ snapshots: domutils: 3.2.1 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@1.1.3: dependencies: mdn-data: 2.0.14 @@ -8669,6 +8778,8 @@ snapshots: emittery@0.13.1: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -8796,6 +8907,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@2.0.0: {} @@ -9179,6 +9292,8 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fflate@0.7.4: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -9404,6 +9519,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hex-rgb@4.3.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -10149,6 +10266,11 @@ snapshots: lilconfig@3.1.3: {} + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: {} lint-staged@15.5.0: @@ -10478,10 +10600,17 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.26.2 @@ -10868,6 +10997,20 @@ snapshots: safer-buffer@2.1.2: {} + 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.2.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 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -11084,6 +11227,8 @@ snapshots: get-east-asian-width: 1.3.0 strip-ansi: 7.1.0 + string.prototype.codepointat@0.2.1: {} + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -11281,6 +11426,8 @@ snapshots: through@2.3.8: {} + tiny-inflate@1.0.3: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -11443,6 +11590,11 @@ snapshots: unicode-property-aliases-ecmascript@2.1.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + universalify@0.2.0: {} universalify@2.0.1: {} @@ -11672,6 +11824,8 @@ snapshots: yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + zustand@5.0.3(@types/react@18.3.18)(react@18.3.1): optionalDependencies: '@types/react': 18.3.18 diff --git a/public/NotoSansKR-Bold.ttf b/public/NotoSansKR-Bold.ttf new file mode 100644 index 0000000..14eabfe Binary files /dev/null and b/public/NotoSansKR-Bold.ttf differ diff --git a/public/NotoSansKR-Medium.ttf b/public/NotoSansKR-Medium.ttf new file mode 100644 index 0000000..0d57152 Binary files /dev/null and b/public/NotoSansKR-Medium.ttf differ diff --git a/public/velog.png b/public/velog.png new file mode 100644 index 0000000..dd2a3c0 Binary files /dev/null and b/public/velog.png differ diff --git a/src/app/api/badge/components/Posts.tsx b/src/app/api/badge/components/Posts.tsx new file mode 100644 index 0000000..2048b6d --- /dev/null +++ b/src/app/api/badge/components/Posts.tsx @@ -0,0 +1,42 @@ +/* eslint-disable react/no-unknown-property */ + +import { COLORS } from '@/constants'; +import { Icon } from '@/shared/Icon'; +import { fontStyle } from '../util'; + +interface IProps { + posts: { + id: string; + title: string; + viewCount: number; + likeCount: number; + createdAt: string; + viewDiff: number; + }[]; +} + +export const Posts = ({ posts }: IProps) => { + return ( +
+ {posts.map((item) => ( +
+ {item.title} + + {item.createdAt} + +
+ + {item.viewCount} / {item.viewDiff}{' '} + /{' '} + + + {item.likeCount} +
+
+ ))} +
+ ); +}; diff --git a/src/app/api/badge/components/PoweredBy.tsx b/src/app/api/badge/components/PoweredBy.tsx new file mode 100644 index 0000000..354a1df --- /dev/null +++ b/src/app/api/badge/components/PoweredBy.tsx @@ -0,0 +1,25 @@ +/* eslint-disable react/no-unknown-property */ + +import { COLORS } from '@/constants'; +import { fontStyle } from '../util'; + +export const PoweredBy = () => { + return ( +
+ Powered By + + + + +
+ ); +}; diff --git a/src/app/api/badge/components/Statistics.tsx b/src/app/api/badge/components/Statistics.tsx new file mode 100644 index 0000000..3ef05fe --- /dev/null +++ b/src/app/api/badge/components/Statistics.tsx @@ -0,0 +1,40 @@ +/* eslint-disable react/no-unknown-property */ + +import { COLORS } from '@/constants'; +import { Icon, NameType } from '@/shared/Icon'; +import { parseNumber } from '@/utils'; +import { fontStyle } from '../util'; + +interface IProp { + assets?: ('views' | 'likes' | 'posts')[]; + totalViews: number; + totalLikes: number; + totalPosts: number; +} +const nameTable = { + views: 'View', + likes: 'Like', + posts: 'Post', +}; + +export const Statistics = ({ assets, totalViews, totalLikes, totalPosts }: IProp) => { + const statistics = { views: totalViews, likes: totalLikes, posts: totalPosts }; + + return ( +
+ {assets?.map((item, index) => ( +
+ + + {parseNumber(statistics[item])} + +
+ ))} +
+ ); +}; diff --git a/src/app/api/badge/components/Title.tsx b/src/app/api/badge/components/Title.tsx new file mode 100644 index 0000000..1f3fb05 --- /dev/null +++ b/src/app/api/badge/components/Title.tsx @@ -0,0 +1,23 @@ +/* eslint-disable react/no-unknown-property */ +/* eslint-disable @next/next/no-img-element */ + +import { COLORS } from '@/constants'; +import { fontStyle } from '../util'; + +interface IProp { + username: string; + origin: string; +} + +export const Title = ({ username, origin }: IProp) => { + return ( +
+ +
+ {username} +
+ {/* transform scale 떄문인지 tw underline을 적용했음에도 밑줄이 보이지 않는 문제가 있음 */} +
+
+ ); +}; diff --git a/src/app/api/badge/components/index.ts b/src/app/api/badge/components/index.ts new file mode 100644 index 0000000..40f0e5b --- /dev/null +++ b/src/app/api/badge/components/index.ts @@ -0,0 +1,4 @@ +export * from './Posts'; +export * from './PoweredBy'; +export * from './Statistics'; +export * from './Title'; diff --git a/src/app/api/badge/route.tsx b/src/app/api/badge/route.tsx new file mode 100644 index 0000000..2d28317 --- /dev/null +++ b/src/app/api/badge/route.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/no-unknown-property */ + +import { NextResponse } from 'next/server'; +import { Posts, PoweredBy, Statistics, Title } from './components'; +import { createImageResponse } from './util'; + +const DATA = { + username: 'six-standard', + totalViews: 12345, + totalLikes: 6789, + totalPosts: 123, + posts: [ + { + id: '1', + title: '제목', + createdAt: '2025-12-16', + viewCount: 123, + viewDiff: 456, + likeCount: 789, + }, + { + id: '1', + title: '제목', + createdAt: '2025-12-16', + viewCount: 123, + viewDiff: 456, + likeCount: 789, + }, + { + id: '1', + title: '제목', + createdAt: '2025-12-16', + viewCount: 123, + viewDiff: 456, + likeCount: 789, + }, + { + id: '1', + title: '제목', + createdAt: '2025-12-16', + viewCount: 123, + viewDiff: 456, + likeCount: 789, + }, + ], +}; + +export async function GET(request: Request) { + const { origin, searchParams } = new URL(request.url); + const size = Number(searchParams.get('size')) || 2; + const username = searchParams.get('username') || ''; + const type = (searchParams.get('type') as 'default' | 'simple') || 'default'; + const assets = searchParams.get('assets')?.split(',') as ('views' | 'likes' | 'posts')[]; + + if (!username) { + return NextResponse.json({ error: "'username' parameter is required" }, { status: 400 }); + } + + if (type === 'simple') { + return await createImageResponse( +
+ + <Statistics + assets={assets} + totalLikes={DATA.totalLikes} + totalPosts={DATA.totalPosts} + totalViews={DATA.totalViews} + /> + <PoweredBy /> + </div>, + { origin, size, type: 'simple' }, + ); + } + + return await createImageResponse( + <div style={{ gap: 16 }} tw="flex flex-col w-full"> + <div tw="flex items-center justify-between w-full"> + <Title username={DATA.username} origin={origin} /> + <Statistics + assets={assets} + totalLikes={DATA.totalLikes} + totalPosts={DATA.totalPosts} + totalViews={DATA.totalViews} + /> + </div> + <Posts posts={DATA.posts} /> + <PoweredBy /> + </div>, + { origin, size, type: 'default' }, + ); +} diff --git a/src/app/api/badge/util.tsx b/src/app/api/badge/util.tsx new file mode 100644 index 0000000..5922119 --- /dev/null +++ b/src/app/api/badge/util.tsx @@ -0,0 +1,70 @@ +/* eslint-disable react/no-unknown-property */ + +import { ImageResponse } from '@vercel/og'; +import { COLORS, FONTS } from '@/constants'; + +type fontType = [string, { lineHeight: string; fontWeight: string }]; + +export const fontStyle = <T extends keyof typeof FONTS>( + type: T, + index: keyof (typeof FONTS)[T], + color?: string, + tw?: string, +) => { + const [size, { lineHeight, fontWeight }] = FONTS[type][index] as fontType; + + return { + style: { fontFamily: `Noto${fontWeight}` }, + tw: `text-[${parseInt(size)}px] leading-[${parseInt(lineHeight)}px] text-[${color}] ${tw}`, + }; +}; + +export const loadFonts = async (url: string) => await (await fetch(new URL(url)))?.arrayBuffer(); + +const sizeTable = { + default: [550, 350, 25], + simple: [350, 140, 20], +}; + +interface options { + origin: string; + type: keyof typeof sizeTable; + size: number; +} + +export const createImageResponse = async (node: React.ReactNode, options: options) => { + const { origin, type, size } = options; + + const NotoBold = await loadFonts(origin + '/NotoSansKR-Bold.ttf'); + const NotoMedium = await loadFonts(origin + '/NotoSansKR-Medium.ttf'); + const [width, height, padding] = sizeTable[type]; + + return new ImageResponse( + ( + <div + style={{ width: width * size, height: height * size }} + tw="flex items-center justify-center" + > + <div + style={{ + width, + height, + padding: `${padding - 5}px ${padding}px`, + transform: `scale(${size})`, + }} + tw={`flex bg-[${COLORS.BG.MAIN}]`} + > + {node} + </div> + </div> + ), + { + width: width * size, + height: height * size, + fonts: [ + { data: NotoMedium, name: 'Noto500', weight: 500 }, + { data: NotoBold, name: 'Noto700', weight: 700 }, + ], + }, + ); +}; diff --git a/src/app/components/BadgeGenerator/index.tsx b/src/app/components/BadgeGenerator/index.tsx new file mode 100644 index 0000000..0a77a72 --- /dev/null +++ b/src/app/components/BadgeGenerator/index.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import Image from 'next/image'; +import { FormEvent, useState } from 'react'; +// import { ENVS } from '@/constants'; +import { twMerge } from 'tailwind-merge'; +import { me } from '@/apis'; +import { PATHS } from '@/constants'; +import { Check, CopyButton, Dropdown, Modal as Layout } from '@/shared'; + +export const BadgeGenerator = () => { + const [type, setType] = useState('default'); + const [assets, setAssets] = useState({ views: true, likes: true, posts: true }); + + const handleChangeAssets = ({ currentTarget }: FormEvent<HTMLInputElement>) => { + const { id, checked } = currentTarget; + setAssets((prev) => ({ ...prev, [id]: checked })); + }; + + const { data: profiles } = useQuery({ + queryKey: [PATHS.ME], + queryFn: me, + staleTime: 1000 * 60 * 5, + retry: 1, + }); + + const selectedAssets = Object.entries(assets) + .filter((value) => value[1]) + .map(([key]) => key); + + return ( + <Layout title="뱃지 생성기"> + <div className="flex justify-between gap-10 max-TBL:flex-col max-TBL:justify-normal"> + <div className="flex flex-col gap-4"> + <div className="flex gap-2 flex-col"> + <span className="text-TEXT-MAIN text-TITLE-5">레이아웃 형태</span> + <Dropdown + options={[ + ['기본 보기', 'default'], + ['간단 보기', 'simple'], + ]} + onChange={(value) => setType(value as string)} + defaultValue={'기본 보기'} + /> + </div> + <div className="flex gap-2 flex-col"> + <span className="text-TEXT-MAIN text-TITLE-5">표시할 통계</span> + <div className="flex items-center gap-5"> + <Check + onChange={handleChangeAssets} + id="views" + checked={assets.views} + label="총 조회수" + /> + <Check + onChange={handleChangeAssets} + id="likes" + checked={assets.likes} + label="총 좋아요 수" + /> + <Check + onChange={handleChangeAssets} + id="posts" + checked={assets.posts} + label="총 게시물 수" + /> + </div> + </div> + <div className="flex gap-2 flex-col"> + <span className="text-TEXT-MAIN text-TITLE-5">HTML 코드 </span> + <CopyButton + url={`<a href="https://velog.io/@${profiles?.username}">\n <img src="http://localhost:3000/api/badge?username=${profiles?.username}&type=${type}${selectedAssets.length ? `&assets=${selectedAssets.join(',')}` : ''}" />\n</a>`} + type="code" + className="max-w-[650px]" + /> + </div> + </div> + <div className="flex gap-2 flex-col"> + <span className="text-TEXT-MAIN text-TITLE-5"> + 미리보기{' '} + <span className="text-TEXT-ALT text-SUBTITLE-5"> + * 내용이 바로 반영되지 않을 수 있습니다 + </span> + </span> + <div + className={twMerge( + 'shrink-0 relative max-MBI:w-full', + type === 'default' + ? 'w-[550px] MBI:h-[350px] max-MBI:max-w-[550px]' + : 'w-[350px] MBI:h-[140px] max-MBI:max-w-[350px]', + )} + > + <Image + fill + key={type} + src={`http://localhost:3000/api/badge?username=${profiles?.username}&size=2&type=${type}&assets=${Object.entries( + assets, + ) + .filter((value) => value[1]) + .map(([key]) => key) + .join(',')}`} + alt="Preview" + /> + </div> + </div> + </div> + </Layout> + ); +}; diff --git a/src/app/components/Header/index.tsx b/src/app/components/Header/index.tsx index 89aa33b..636e60c 100644 --- a/src/app/components/Header/index.tsx +++ b/src/app/components/Header/index.tsx @@ -11,6 +11,7 @@ import { useResponsive, useModal } from '@/hooks'; import { Icon, NameType } from '@/shared'; import { revalidate } from '@/utils'; import { defaultStyle, Section, textStyle } from './Section'; +import { BadgeGenerator } from '../BadgeGenerator'; import { Modal } from '../Notice/Modal'; import { QRCode } from '../QRCode'; @@ -124,6 +125,15 @@ export const Header = () => { > QR 로그인 </button> + <button + className="text-TEXT-MAIN text-INPUT-3 p-5 max-MBI:p-4 flex items-center justify-center whitespace-nowrap w-full hover:bg-BG-ALT" + onClick={() => { + setOpen(false); + ModalOpen(<BadgeGenerator />); + }} + > + 뱃지 생성기 + </button> </div> </div> )} diff --git a/src/app/components/QRCode/index.tsx b/src/app/components/QRCode/index.tsx index a4b0cf0..bc6fcfe 100644 --- a/src/app/components/QRCode/index.tsx +++ b/src/app/components/QRCode/index.tsx @@ -7,9 +7,8 @@ import { twJoin } from 'tailwind-merge'; import { createQRToken } from '@/apis'; import { COLORS, ENVS, PATHS, SCREENS } from '@/constants'; import { useResponsive } from '@/hooks'; -import { Inform, Modal as Layout } from '@/shared'; +import { Inform, Modal as Layout, CopyButton } from '@/shared'; import { formatTimeToMMSS } from '@/utils'; -import { CopyButton } from './CopyButton'; const TIMER_DURATION = 5 * 60; // 5분 = 300초 diff --git a/src/app/components/Section/index.tsx b/src/app/components/Section/index.tsx index 63cd8a1..82e2f47 100644 --- a/src/app/components/Section/index.tsx +++ b/src/app/components/Section/index.tsx @@ -52,7 +52,7 @@ export const Section = forwardRef<HTMLElement, PostType>((p, ref) => { <Icon name="Like" color={COLORS.PRIMARY.SUB} - className="w-[23px] max-TBL:w-[19px] max-MBI:w-[16px]" + className="w-[20px] max-TBL:w-[18px] max-MBI:w-[14px]" /> <span>{parseNumber(p.likes)}</span> <Icon diff --git a/src/shared/Check.tsx b/src/shared/Check.tsx index 5952ace..d17c370 100644 --- a/src/shared/Check.tsx +++ b/src/shared/Check.tsx @@ -3,7 +3,7 @@ import { twJoin } from 'tailwind-merge'; interface IProp extends React.HTMLAttributes<HTMLInputElement> { checked: boolean; label?: string; - onChange: React.FormEventHandler<HTMLDivElement> | undefined; + onChange: React.FormEventHandler<HTMLInputElement> | undefined; direction?: 'left' | 'right'; } diff --git a/src/app/components/QRCode/CopyButton.tsx b/src/shared/CopyButton.tsx similarity index 51% rename from src/app/components/QRCode/CopyButton.tsx rename to src/shared/CopyButton.tsx index 33b3961..109f253 100644 --- a/src/app/components/QRCode/CopyButton.tsx +++ b/src/shared/CopyButton.tsx @@ -1,13 +1,15 @@ -import { useEffect, useRef, useState } from 'react'; +import { HTMLProps, useEffect, useRef, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; const ROLLBACK_AFTER_CLICK_MS = 1000; -interface IProp { +interface IProp extends HTMLProps<HTMLButtonElement> { + type?: 'default' | 'code'; url?: string; disabled?: boolean; } -export const CopyButton = ({ url, disabled }: IProp) => { +export const CopyButton = ({ url, type, disabled, ...rest }: IProp) => { const [clicked, setClicked] = useState(false); const clickedRef = useRef<NodeJS.Timeout | null>(null); @@ -32,11 +34,42 @@ export const CopyButton = ({ url, disabled }: IProp) => { } }; + if (type === 'code') { + return ( + <button + {...rest} + onClick={handleClick} + disabled={disabled} + className={twMerge( + ` + relative bg-BG-MAIN w-fit max-w-full p-5 rounded-lg overflow-hidden transition-all duration-200 + after:absolute after:inset-0 after:flex after:items-center after:justify-center + after:rounded-lg after:transition-all after:duration-300 after:font-medium after:pointer-events-none + ${ + disabled + ? 'cursor-not-allowed bg-BG-ALT text-TEXT-SUB opacity-50' + : clicked + ? 'cursor-pointer bg-BG-MAIN text-TEXT-MAIN hover:shadow-lg after:content-["복사_완료!"] after:bg-PRIMARY-SUB after:text-BG-MAIN after:opacity-100 after:scale-100' + : 'cursor-pointer bg-BG-MAIN text-TEXT-MAIN hover:shadow-lg after:content-["클릭해서_복사하기"] after:bg-BG-MAIN after:text-TEXT-MAIN after:opacity-0 after:scale-95 hover:after:opacity-100 hover:after:scale-100' + } + `, + rest.className, + )} + > + <code className="block text-left break-words whitespace-pre-wrap w-fit text-TEXT-MAIN"> + {url} + </code> + </button> + ); + } + return ( <button + {...rest} onClick={handleClick} disabled={disabled} - className={` + className={twMerge( + ` relative block p-4 rounded-lg leading-none overflow-hidden transition-all duration-200 after:absolute after:inset-0 after:flex after:items-center after:justify-center truncate after:rounded-lg after:transition-all after:duration-300 after:font-medium after:pointer-events-none @@ -47,7 +80,9 @@ export const CopyButton = ({ url, disabled }: IProp) => { ? 'cursor-pointer bg-BG-MAIN text-TEXT-MAIN hover:shadow-lg after:content-["복사_완료!"] after:bg-PRIMARY-SUB after:text-BG-MAIN after:opacity-100 after:scale-100' : 'cursor-pointer bg-BG-MAIN text-TEXT-MAIN hover:shadow-lg after:content-["클릭해서_복사하기"] after:bg-BG-MAIN after:text-TEXT-MAIN after:opacity-0 after:scale-95 hover:after:opacity-100 hover:after:scale-100' } - `} + `, + rest.className, + )} > {url} </button> diff --git a/src/shared/Icon/icons/Like.svg b/src/shared/Icon/icons/Like.svg index 61a035c..f89fdf0 100644 --- a/src/shared/Icon/icons/Like.svg +++ b/src/shared/Icon/icons/Like.svg @@ -1,3 +1,3 @@ <svg width="currentWidth" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M28.75 12.5C28.75 11.837 28.4866 11.2011 28.0178 10.7322C27.5489 10.2634 26.913 10 26.25 10H18.35L19.55 4.2875C19.575 4.1625 19.5875 4.025 19.5875 3.8875C19.5875 3.375 19.375 2.9 19.0375 2.5625L17.7125 1.25L9.4875 9.475C9.025 9.9375 8.75 10.5625 8.75 11.25V23.75C8.75 24.413 9.01339 25.0489 9.48223 25.5178C9.95107 25.9866 10.587 26.25 11.25 26.25H22.5C23.5375 26.25 24.425 25.625 24.8 24.725L28.575 15.9125C28.6875 15.625 28.75 15.325 28.75 15V12.5ZM1.25 26.25H6.25V11.25H1.25V26.25Z" fill="currentColor"/> +<path d="M30 12.3864C30 11.6631 29.7127 10.9694 29.2012 10.4579C28.6897 9.94644 27.996 9.6591 27.2727 9.6591H18.6545L19.9636 3.42728C19.9909 3.29092 20.0045 3.14092 20.0045 2.99092C20.0045 2.43183 19.7727 1.91365 19.4045 1.54547L17.9591 0.113647L8.98636 9.08638C8.48182 9.59092 8.18182 10.2727 8.18182 11.0227V24.6591C8.18182 25.3824 8.46916 26.0761 8.98062 26.5876C9.49208 27.099 10.1858 27.3864 10.9091 27.3864H23.1818C24.3136 27.3864 25.2818 26.7046 25.6909 25.7227L29.8091 16.1091C29.9318 15.7955 30 15.4682 30 15.1136V12.3864ZM0 27.3864H5.45455V11.0227H0V27.3864Z" fill="currentColor"/> </svg> diff --git a/src/shared/Icon/icons/Post.svg b/src/shared/Icon/icons/Post.svg new file mode 100644 index 0000000..14527f7 --- /dev/null +++ b/src/shared/Icon/icons/Post.svg @@ -0,0 +1,5 @@ +<svg width="currentWidth" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1143_117)"> +<path d="M0 30V0H30V30H0ZM25 23.3333H5V25.8333H25V23.3333ZM5 20.8333H25V18.3333H5V20.8333ZM5 15H25V5H5V15Z" fill="currentColor"/> +</g> +</svg> diff --git a/src/shared/Icon/icons/View.svg b/src/shared/Icon/icons/View.svg new file mode 100644 index 0000000..1e17dbd --- /dev/null +++ b/src/shared/Icon/icons/View.svg @@ -0,0 +1,3 @@ +<svg width="currentWidth" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M30 15C30 14.386 29.6682 13.9841 29.0046 13.1768C26.5758 10.2281 21.2189 4.73779 15 4.73779C8.78107 4.73779 3.42417 10.2281 0.995439 13.1768C0.331813 13.9841 0 14.386 0 15C0 15.6141 0.331813 16.016 0.995439 16.8233C3.42417 19.772 8.78107 25.2623 15 25.2623C21.2189 25.2623 26.5758 19.772 29.0046 16.8233C29.6682 16.016 30 15.6141 30 15ZM15 20.1312C16.3609 20.1312 17.666 19.5906 18.6283 18.6283C19.5905 17.666 20.1311 16.3609 20.1311 15C20.1311 13.6392 19.5905 12.3341 18.6283 11.3718C17.666 10.4095 16.3609 9.86892 15 9.86892C13.6391 9.86892 12.334 10.4095 11.3717 11.3718C10.4095 12.3341 9.86887 13.6392 9.86887 15C9.86887 16.3609 10.4095 17.666 11.3717 18.6283C12.334 19.5906 13.6391 20.1312 15 20.1312Z" fill="currentColor"/> +</svg> diff --git a/src/shared/Icon/icons/index.ts b/src/shared/Icon/icons/index.ts index c76a218..66ee18d 100644 --- a/src/shared/Icon/icons/index.ts +++ b/src/shared/Icon/icons/index.ts @@ -7,3 +7,5 @@ export { default as Close } from './Close.svg'; export { default as Loudspeaker } from './Loudspeaker.svg'; export { default as Door } from './Door.svg'; export { default as Video } from './Video.svg'; +export { default as Post } from './Post.svg'; +export { default as View } from './View.svg'; diff --git a/src/shared/Icon/index.tsx b/src/shared/Icon/index.tsx index af2fb91..161e6ba 100644 --- a/src/shared/Icon/index.tsx +++ b/src/shared/Icon/index.tsx @@ -2,9 +2,12 @@ import * as Icons from './icons'; export { default as SvgrMock } from './SvgrMock'; export type NameType = keyof typeof Icons; -type iconType = Record<NameType, React.JSXElementConstructor<React.SVGProps<SVGSVGElement>>>; -interface IProp extends React.SVGProps<SVGSVGElement> { +type SVGPropsWithTW = React.SVGProps<SVGSVGElement> & { tw?: string }; + +type iconType = Record<NameType, React.JSXElementConstructor<SVGPropsWithTW>>; + +interface IProp extends SVGPropsWithTW { name: NameType; size?: number; color?: string; @@ -27,10 +30,11 @@ export const Icon = ({ name, size = 30, color = '#ACACAC', rotate = 'up', ...res return ( <Comp {...rest} - style={{ color }} + style={{ color, ...rest.style }} width={size} height={size} className={`transition-all duration-300 shrink-0 ${rotates[rotate]} ${rest.className}`} + tw={`transition-all duration-300 shrink-0 ${rotates[rotate]} ${rest.tw}`} /> ); }; diff --git a/src/shared/index.ts b/src/shared/index.ts index 48a9825..33e2105 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -6,3 +6,4 @@ export * from './Check'; export * from './Modal'; export * from './Icon'; export * from './EmptyState'; +export * from './CopyButton';