├── .envrc.template ├── .github ├── actions │ └── setup-frontend │ │ └── action.yml └── workflows │ ├── _ci.yml │ ├── _e2e.yml │ ├── ci.yml │ ├── e2e.yml │ └── release-please.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── ctrl.sh ├── frontend ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── e2e │ └── workflow.yml ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── logo.svg │ └── logos │ │ ├── atcoder_black.svg │ │ ├── atcoder_white.svg │ │ ├── bluesky.svg │ │ ├── qiita.png │ │ └── zenn.svg ├── src │ ├── api │ │ ├── api.ts │ │ ├── atcoder.ts │ │ ├── bluesky.ts │ │ ├── qiita.ts │ │ └── zenn.ts │ ├── components │ │ ├── Layout │ │ │ ├── Layout.tsx │ │ │ ├── LayoutFooter.tsx │ │ │ ├── LayoutHeader.tsx │ │ │ └── index.ts │ │ ├── pages │ │ │ ├── HomePage │ │ │ │ ├── BadgeBlocks.tsx │ │ │ │ ├── HomePage.tsx │ │ │ │ ├── ServiceCard.tsx │ │ │ │ └── index.ts │ │ │ └── PrivacyPolicyPage │ │ │ │ ├── PrivacyPolicyItem.tsx │ │ │ │ ├── PrivacyPolicyPage.tsx │ │ │ │ └── index.ts │ │ └── util │ │ │ ├── BadgeBlock.tsx │ │ │ ├── Disclosure.tsx │ │ │ ├── Divider.tsx │ │ │ ├── Input.tsx │ │ │ └── Link.tsx │ ├── index.d.ts │ ├── lib │ │ ├── api │ │ │ ├── api.ts │ │ │ ├── atcoderApi.ts │ │ │ ├── axios.ts │ │ │ ├── bluesky.ts │ │ │ ├── cache.ts │ │ │ ├── firestore.ts │ │ │ ├── qiitaApi.ts │ │ │ ├── rate.ts │ │ │ └── zennApi.ts │ │ ├── badge.ts │ │ ├── badgeUrl.ts │ │ ├── logger.ts │ │ └── renderBadge.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ ├── atcoder │ │ │ │ └── [username] │ │ │ │ │ └── rating │ │ │ │ │ ├── algorithm.ts │ │ │ │ │ └── heuristic.ts │ │ │ ├── bluesky │ │ │ │ └── [username] │ │ │ │ │ ├── followers.ts │ │ │ │ │ └── posts.ts │ │ │ ├── qiita │ │ │ │ └── [username] │ │ │ │ │ ├── articles.ts │ │ │ │ │ ├── contributions.ts │ │ │ │ │ └── followers.ts │ │ │ └── zenn │ │ │ │ └── [username] │ │ │ │ ├── articles.ts │ │ │ │ ├── books.ts │ │ │ │ ├── followers.ts │ │ │ │ ├── likes.ts │ │ │ │ └── scraps.ts │ │ ├── index.tsx │ │ └── privacy.tsx │ ├── styles │ │ └── global.css │ └── tasks │ │ └── buildLogos.ts ├── tailwind.config.js └── tsconfig.json ├── mise.toml ├── renovate.json └── terraform ├── .gitignore ├── app ├── .terraform.lock.hcl ├── app_engine.tf ├── artifact_registry.tf ├── backend.tf ├── cloud_run.tf ├── locals.tf ├── project.tf ├── provider.tf ├── sa.tf └── versions.tf └── github-actions ├── .terraform.lock.hcl ├── backend.tf ├── locals.tf ├── outputs.tf ├── project.tf ├── provider.tf ├── sa.tf ├── versions.tf └── workload_identity.tf /.envrc.template: -------------------------------------------------------------------------------- 1 | export QIITA_ACCESS_TOKEN="" 2 | export BLUESKY_IDENTIFIER="" 3 | export BLUESKY_PASSWORD="" 4 | export GA_MEASUREMENT_ID="" 5 | -------------------------------------------------------------------------------- /.github/actions/setup-frontend/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Frontend 2 | description: Setup Frontend 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - uses: jdx/mise-action@7a111ead46986ccad89a74ad013ba2a7c08c9e67 # v2.2.1 8 | 9 | - name: Get npm cache directory 10 | id: cache-dir 11 | shell: bash 12 | run: echo "dir=$(npm config get cache)" >> "${GITHUB_OUTPUT}" 13 | working-directory: frontend 14 | 15 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 16 | with: 17 | path: ${{ steps.cache-dir.outputs.dir }} 18 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 19 | restore-keys: | 20 | ${{ runner.os }}-node- 21 | 22 | - run: npm ci 23 | working-directory: frontend 24 | shell: bash 25 | -------------------------------------------------------------------------------- /.github/workflows/_ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: frontend 12 | steps: 13 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 14 | - uses: ./.github/actions/setup-frontend 15 | 16 | - run: npm run build 17 | -------------------------------------------------------------------------------- /.github/workflows/_e2e.yml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | e2e: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: frontend 12 | steps: 13 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 14 | - uses: ./.github/actions/setup-frontend 15 | - run: npm run e2e 16 | env: 17 | STEPCI_DISABLE_ANALYTICS: "1" 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | ci: 15 | uses: ./.github/workflows/_ci.yml 16 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | schedule: 5 | - cron: '0 3,6,9,12,15,18,21 * * *' 6 | 7 | jobs: 8 | e2e: 9 | uses: ./.github/workflows/_e2e.yml 10 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }} 10 | 11 | jobs: 12 | # TODO: release-please 13 | 14 | build: 15 | permissions: 16 | contents: 'read' 17 | id-token: 'write' 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 21 | - run: ./bin/ctrl.sh build 22 | env: 23 | GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }} 24 | - uses: google-github-actions/auth@09cecabe1f169596b81c2ef22b40faff87acc460 # v0.9.0 25 | with: 26 | workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} 27 | service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} 28 | - run: ./bin/ctrl.sh push 29 | 30 | deploy: 31 | needs: 32 | - build 33 | permissions: 34 | contents: 'read' 35 | id-token: 'write' 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: checkout 39 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 40 | - uses: google-github-actions/auth@09cecabe1f169596b81c2ef22b40faff87acc460 # v0.9.0 41 | with: 42 | workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} 43 | service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} 44 | - run: ./bin/ctrl.sh deploy 45 | env: 46 | QIITA_ACCESS_TOKEN: ${{ secrets.QIITA_ACCESS_TOKEN }} 47 | BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }} 48 | BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} 49 | - run: ./bin/ctrl.sh clean_images 50 | 51 | e2e: 52 | needs: 53 | - deploy 54 | uses: ./.github/workflows/_e2e.yml 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Koki Sato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Actions](https://github.com/koki-develop/badge-generator/actions/workflows/ci.yml/badge.svg)](https://github.com/koki-develop/badge-generator/actions/workflows/ci.yml) 2 | [![Twitter Follow](https://img.shields.io/twitter/follow/koki_develop?style=social)](https://twitter.com/koki_develop) 3 | 4 | # Badge Generator 5 | 6 | [badgen.org](https://badgen.org) - シンプルなバッジ生成サービス。 7 | 8 | ## Badges 9 | 10 | ### Zenn 11 | 12 | [![Likes](https://badgen.org/img/zenn/kou_pg_0131/likes?style=plastic)](https://zenn.dev/kou_pg_0131) 13 | [![Followers](https://badgen.org/img/zenn/kou_pg_0131/followers?style=plastic)](https://zenn.dev/kou_pg_0131) 14 | [![Articles](https://badgen.org/img/zenn/kou_pg_0131/articles?style=plastic)](https://zenn.dev/kou_pg_0131) 15 | [![Books](https://badgen.org/img/zenn/kou_pg_0131/books?style=plastic)](https://zenn.dev/kou_pg_0131?tab=books) 16 | [![Scraps](https://badgen.org/img/zenn/kou_pg_0131/scraps?style=plastic)](https://zenn.dev/kou_pg_0131?tab=scraps) 17 | 18 | [![Likes](https://badgen.org/img/zenn/kou_pg_0131/likes?style=flat)](https://zenn.dev/kou_pg_0131) 19 | [![Followers](https://badgen.org/img/zenn/kou_pg_0131/followers?style=flat)](https://zenn.dev/kou_pg_0131) 20 | [![Articles](https://badgen.org/img/zenn/kou_pg_0131/articles?style=flat)](https://zenn.dev/kou_pg_0131) 21 | [![Books](https://badgen.org/img/zenn/kou_pg_0131/books?style=flat)](https://zenn.dev/kou_pg_0131?tab=books) 22 | [![Scraps](https://badgen.org/img/zenn/kou_pg_0131/scraps?style=flat)](https://zenn.dev/kou_pg_0131?tab=scraps) 23 | 24 | [![Likes](https://badgen.org/img/zenn/kou_pg_0131/likes?style=flat-square)](https://zenn.dev/kou_pg_0131) 25 | [![Followers](https://badgen.org/img/zenn/kou_pg_0131/followers?style=flat-square)](https://zenn.dev/kou_pg_0131) 26 | [![Articles](https://badgen.org/img/zenn/kou_pg_0131/articles?style=flat-square)](https://zenn.dev/kou_pg_0131) 27 | [![Books](https://badgen.org/img/zenn/kou_pg_0131/books?style=flat-square)](https://zenn.dev/kou_pg_0131?tab=books) 28 | [![Scraps](https://badgen.org/img/zenn/kou_pg_0131/scraps?style=flat-square)](https://zenn.dev/kou_pg_0131?tab=scraps) 29 | 30 | [![Likes](https://badgen.org/img/zenn/kou_pg_0131/likes?style=social)](https://zenn.dev/kou_pg_0131) 31 | [![Followers](https://badgen.org/img/zenn/kou_pg_0131/followers?style=social)](https://zenn.dev/kou_pg_0131) 32 | [![Articles](https://badgen.org/img/zenn/kou_pg_0131/articles?style=social)](https://zenn.dev/kou_pg_0131) 33 | [![Books](https://badgen.org/img/zenn/kou_pg_0131/books?style=social)](https://zenn.dev/kou_pg_0131?tab=books) 34 | [![Scraps](https://badgen.org/img/zenn/kou_pg_0131/scraps?style=social)](https://zenn.dev/kou_pg_0131?tab=scraps) 35 | 36 | [![Likes](https://badgen.org/img/zenn/kou_pg_0131/likes?style=for-the-badge)](https://zenn.dev/kou_pg_0131) 37 | [![Followers](https://badgen.org/img/zenn/kou_pg_0131/followers?style=for-the-badge)](https://zenn.dev/kou_pg_0131) 38 | [![Articles](https://badgen.org/img/zenn/kou_pg_0131/articles?style=for-the-badge)](https://zenn.dev/kou_pg_0131) 39 | [![Books](https://badgen.org/img/zenn/kou_pg_0131/books?style=for-the-badge)](https://zenn.dev/kou_pg_0131?tab=books) 40 | [![Scraps](https://badgen.org/img/zenn/kou_pg_0131/scraps?style=for-the-badge)](https://zenn.dev/kou_pg_0131?tab=scraps) 41 | 42 | ### Qiita 43 | 44 | [![Contributions](https://badgen.org/img/qiita/koki_develop/contributions?style=plastic)](https://qiita.com/koki_develop) 45 | [![Followers](https://badgen.org/img/qiita/koki_develop/followers?style=plastic)](https://qiita.com/koki_develop) 46 | [![Articles](https://badgen.org/img/qiita/koki_develop/articles?style=plastic)](https://qiita.com/koki_develop) 47 | 48 | [![Contributions](https://badgen.org/img/qiita/koki_develop/contributions?style=flat)](https://qiita.com/koki_develop) 49 | [![Followers](https://badgen.org/img/qiita/koki_develop/followers?style=flat)](https://qiita.com/koki_develop) 50 | [![Articles](https://badgen.org/img/qiita/koki_develop/articles?style=flat)](https://qiita.com/koki_develop) 51 | 52 | [![Contributions](https://badgen.org/img/qiita/koki_develop/contributions?style=flat-square)](https://qiita.com/koki_develop) 53 | [![Followers](https://badgen.org/img/qiita/koki_develop/followers?style=flat-square)](https://qiita.com/koki_develop) 54 | [![Articles](https://badgen.org/img/qiita/koki_develop/articles?style=flat-square)](https://qiita.com/koki_develop) 55 | 56 | [![Contributions](https://badgen.org/img/qiita/koki_develop/contributions?style=social)](https://qiita.com/koki_develop) 57 | [![Followers](https://badgen.org/img/qiita/koki_develop/followers?style=social)](https://qiita.com/koki_develop) 58 | [![Articles](https://badgen.org/img/qiita/koki_develop/articles?style=social)](https://qiita.com/koki_develop) 59 | 60 | [![Contributions](https://badgen.org/img/qiita/koki_develop/contributions?style=for-the-badge)](https://qiita.com/koki_develop) 61 | [![Followers](https://badgen.org/img/qiita/koki_develop/followers?style=for-the-badge)](https://qiita.com/koki_develop) 62 | [![Articles](https://badgen.org/img/qiita/koki_develop/articles?style=for-the-badge)](https://qiita.com/koki_develop) 63 | 64 | ### AtCoder 65 | 66 | > [!NOTE] 67 | > [chokudai](https://atcoder.jp/users/chokudai) さんのバッジを表示しています。僕のではありません。 68 | 69 | [![Rating](https://badgen.org/img/atcoder/chokudai/rating/algorithm?style=plastic)](https://atcoder.jp/users/chokudai?contestType=algo) 70 | [![Rating(Heuristic)](https://badgen.org/img/atcoder/chokudai/rating/heuristic?style=plastic)](https://atcoder.jp/users/chokudai?contestType=heuristic) 71 | 72 | [![Rating](https://badgen.org/img/atcoder/chokudai/rating/algorithm?style=flat)](https://atcoder.jp/users/chokudai?contestType=algo) 73 | [![Rating(Heuristic)](https://badgen.org/img/atcoder/chokudai/rating/heuristic?style=flat)](https://atcoder.jp/users/chokudai?contestType=heuristic) 74 | 75 | [![Rating](https://badgen.org/img/atcoder/chokudai/rating/algorithm?style=flat-square)](https://atcoder.jp/users/chokudai?contestType=algo) 76 | [![Rating(Heuristic)](https://badgen.org/img/atcoder/chokudai/rating/heuristic?style=flat-square)](https://atcoder.jp/users/chokudai?contestType=heuristic) 77 | 78 | [![Rating](https://badgen.org/img/atcoder/chokudai/rating/algorithm?style=social)](https://atcoder.jp/users/chokudai?contestType=algo) 79 | [![Rating(Heuristic)](https://badgen.org/img/atcoder/chokudai/rating/heuristic?style=social)](https://atcoder.jp/users/chokudai?contestType=heuristic) 80 | 81 | [![Rating](https://badgen.org/img/atcoder/chokudai/rating/algorithm?style=for-the-badge)](https://atcoder.jp/users/chokudai?contestType=algo) 82 | [![Rating(Heuristic)](https://badgen.org/img/atcoder/chokudai/rating/heuristic?style=for-the-badge)](https://atcoder.jp/users/chokudai?contestType=heuristic) 83 | 84 | ### Bluesky 85 | 86 | [![Followers](https://badgen.org/img/bluesky/koki.me/followers?style=plastic)](https://bsky.app/profile/koki.me) 87 | [![Posts](https://badgen.org/img/bluesky/koki.me/posts?style=plastic)](https://bsky.app/profile/koki.me) 88 | 89 | [![Followers](https://badgen.org/img/bluesky/koki.me/followers?style=flat)](https://bsky.app/profile/koki.me) 90 | [![Posts](https://badgen.org/img/bluesky/koki.me/posts?style=flat)](https://bsky.app/profile/koki.me) 91 | 92 | [![Followers](https://badgen.org/img/bluesky/koki.me/followers?style=flat-square)](https://bsky.app/profile/koki.me) 93 | [![Posts](https://badgen.org/img/bluesky/koki.me/posts?style=flat-square)](https://bsky.app/profile/koki.me) 94 | 95 | [![Followers](https://badgen.org/img/bluesky/koki.me/followers?style=social)](https://bsky.app/profile/koki.me) 96 | [![Posts](https://badgen.org/img/bluesky/koki.me/posts?style=social)](https://bsky.app/profile/koki.me) 97 | 98 | [![Followers](https://badgen.org/img/bluesky/koki.me/followers?style=for-the-badge)](https://bsky.app/profile/koki.me) 99 | [![Posts](https://badgen.org/img/bluesky/koki.me/posts?style=for-the-badge)](https://bsky.app/profile/koki.me) 100 | 101 | ## LICENSE 102 | 103 | [MIT](./LICENSE) 104 | -------------------------------------------------------------------------------- /bin/ctrl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "${#}" -lt 1 ]; then 6 | echo "Usage: 7 | ${0} 8 | 9 | Example: 10 | $ ${0} build 11 | $ ${0} push 12 | $ ${0} build push" 13 | exit 1 14 | fi 15 | 16 | # ---------- 17 | # arguments 18 | # ---------- 19 | 20 | readonly COMMANDS=( "${@}" ) 21 | echo "Arguments:" 22 | echo " COMMANDS=${COMMANDS[*]}" 23 | echo "" 24 | 25 | # ---------- 26 | # constants 27 | # ---------- 28 | 29 | readonly GCP_PROJECT_ID=badge-generator 30 | readonly REGION=asia-northeast1 31 | readonly REGISTRY_HOST=${REGION}-docker.pkg.dev 32 | readonly DOCKER_IMAGE=${REGISTRY_HOST}/${GCP_PROJECT_ID}/app/frontend 33 | readonly SERVICE_ACCOUNT=badge-generator-frontend@${GCP_PROJECT_ID}.iam.gserviceaccount.com 34 | 35 | echo "Constants:" 36 | echo " GCP_PROJECT_ID=${GCP_PROJECT_ID}" 37 | echo " REGION=${REGION}" 38 | echo " REGISTRY_HOST=${REGISTRY_HOST}" 39 | echo " DOCKER_IMAGE=${DOCKER_IMAGE}" 40 | echo " SERVICE_ACCOUNT=${SERVICE_ACCOUNT}" 41 | echo "" 42 | 43 | # ---------- 44 | # functions 45 | # ---------- 46 | 47 | function build() { 48 | docker build \ 49 | -t "${DOCKER_IMAGE}:latest" \ 50 | --platform=linux/amd64 \ 51 | --build-arg GA_MEASUREMENT_ID="${GA_MEASUREMENT_ID}" \ 52 | ./frontend 53 | } 54 | 55 | function push() { 56 | gcloud auth configure-docker "${REGISTRY_HOST}" --quiet 57 | docker push "${DOCKER_IMAGE}:latest" 58 | } 59 | 60 | function deploy() { 61 | gcloud run deploy frontend \ 62 | --image="${DOCKER_IMAGE}:latest" \ 63 | --set-env-vars=QIITA_ACCESS_TOKEN="${QIITA_ACCESS_TOKEN}" \ 64 | --set-env-vars=BLUESKY_IDENTIFIER="${BLUESKY_IDENTIFIER}" \ 65 | --set-env-vars=BLUESKY_PASSWORD="${BLUESKY_PASSWORD}" \ 66 | --region="${REGION}" \ 67 | --project="${GCP_PROJECT_ID}" \ 68 | --service-account="${SERVICE_ACCOUNT}" 69 | gcloud run services update-traffic frontend \ 70 | --to-latest \ 71 | --region="${REGION}" \ 72 | --project="${GCP_PROJECT_ID}" 73 | } 74 | 75 | function list_old_image_digests() { 76 | gcloud artifacts docker images list "${DOCKER_IMAGE}" \ 77 | --include-tags \ 78 | --filter="TAGS!=latest" \ 79 | --format="value(DIGEST)" 80 | } 81 | 82 | function clean_images() { 83 | for _digest in $(list_old_image_digests); do 84 | gcloud artifacts docker images delete "${DOCKER_IMAGE}@${_digest}" --quiet 85 | done 86 | } 87 | 88 | function commands_contains() { 89 | local _right="${1}" 90 | 91 | for _command in "${COMMANDS[@]}"; do 92 | if [ "${_command}" = "${_right}" ]; then 93 | return 0 94 | fi 95 | done 96 | return 1 97 | } 98 | 99 | # ---------- 100 | # main process 101 | # ---------- 102 | 103 | readonly VALID_COMMANDS=( "build" "push" "deploy" "clean_images" ) 104 | for _valid_command in "${VALID_COMMANDS[@]}"; do 105 | if commands_contains "${_valid_command}"; then $_valid_command; fi 106 | done 107 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | .next 5 | .git 6 | .env.local.template 7 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next/core-web-vitals", "prettier"], 3 | plugins: ["unused-imports", "@typescript-eslint"], 4 | rules: { 5 | "unused-imports/no-unused-imports": "error", 6 | "@typescript-eslint/no-unused-vars": [ 7 | "error", 8 | { 9 | argsIgnorePattern: "^_", 10 | varsIgnorePattern: "^_", 11 | }, 12 | ], 13 | "import/order": [ 14 | "error", 15 | { 16 | groups: [ 17 | "builtin", 18 | "external", 19 | "internal", 20 | ["parent", "sibling"], 21 | "object", 22 | "type", 23 | "index", 24 | ], 25 | pathGroupsExcludedImportTypes: ["builtin"], 26 | alphabetize: { order: "asc", caseInsensitive: true }, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # generated 39 | src/logos.json 40 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine AS deps 2 | WORKDIR /app 3 | COPY package.json package-lock.json ./ 4 | RUN npm ci 5 | 6 | FROM node:16-alpine AS builder 7 | WORKDIR /app 8 | ENV NODE_ENV production 9 | ARG GA_MEASUREMENT_ID 10 | ENV NEXT_PUBLIC_GA_MEASUREMENT_ID $GA_MEASUREMENT_ID 11 | COPY --from=deps /app/node_modules ./node_modules 12 | COPY . . 13 | RUN npm run build 14 | 15 | FROM node:16-alpine AS runner 16 | WORKDIR /app 17 | ENV NODE_ENV production 18 | COPY --from=builder /app/public ./public 19 | COPY --from=builder /app/.next/standalone ./ 20 | COPY --from=builder /app/.next/static ./.next/static 21 | 22 | CMD ["node", "server.js"] 23 | -------------------------------------------------------------------------------- /frontend/e2e/workflow.yml: -------------------------------------------------------------------------------- 1 | version: "1.1" 2 | name: Test Badges 3 | config: 4 | http: 5 | baseURL: https://badgen.org 6 | env: 7 | zennUsername: kou_pg_0131 8 | qiitaUsername: koki_develop 9 | atcoderUsername: chokudai 10 | blueskyUsername: koki.me 11 | tests: 12 | app: 13 | steps: 14 | - name: Home 15 | http: 16 | url: / 17 | method: GET 18 | check: 19 | status: 200 20 | headers: 21 | content-type: text/html; charset=utf-8 22 | - name: Privacy Policy 23 | http: 24 | url: /privacy 25 | method: GET 26 | check: 27 | status: 200 28 | headers: 29 | content-type: text/html; charset=utf-8 30 | 31 | zenn: 32 | steps: 33 | - name: Zenn - Likes 34 | http: 35 | url: /img/zenn/${{env.zennUsername}}/likes 36 | method: GET 37 | check: 38 | status: 200 39 | headers: 40 | content-type: image/svg+xml 41 | - name: Zenn - Followers 42 | http: 43 | url: /img/zenn/${{env.zennUsername}}/followers 44 | method: GET 45 | check: 46 | status: 200 47 | headers: 48 | content-type: image/svg+xml 49 | - name: Zenn - Articles 50 | http: 51 | url: /img/zenn/${{env.zennUsername}}/articles 52 | method: GET 53 | check: 54 | status: 200 55 | headers: 56 | content-type: image/svg+xml 57 | - name: Zenn - Books 58 | http: 59 | url: /img/zenn/${{env.zennUsername}}/books 60 | method: GET 61 | check: 62 | status: 200 63 | headers: 64 | content-type: image/svg+xml 65 | - name: Zenn - Scraps 66 | http: 67 | url: /img/zenn/${{env.zennUsername}}/scraps 68 | method: GET 69 | check: 70 | status: 200 71 | headers: 72 | content-type: image/svg+xml 73 | 74 | qiita: 75 | steps: 76 | - name: Qiita - Contributions 77 | http: 78 | url: /img/qiita/${{env.qiitaUsername}}/contributions 79 | method: GET 80 | check: 81 | status: 200 82 | headers: 83 | content-type: image/svg+xml 84 | - name: Qiita - Followers 85 | http: 86 | url: /img/qiita/${{env.qiitaUsername}}/followers 87 | method: GET 88 | check: 89 | status: 200 90 | headers: 91 | content-type: image/svg+xml 92 | - name: Qiita - Articles 93 | http: 94 | url: /img/qiita/${{env.qiitaUsername}}/articles 95 | method: GET 96 | check: 97 | status: 200 98 | headers: 99 | content-type: image/svg+xml 100 | 101 | atcoder: 102 | steps: 103 | - name: AtCoder - Rating 104 | http: 105 | url: /img/atcoder/${{env.atcoderUsername}}/rating/algorithm 106 | method: GET 107 | check: 108 | status: 200 109 | headers: 110 | content-type: image/svg+xml 111 | - name: AtCoder - Rating(Heuristic) 112 | http: 113 | url: /img/atcoder/${{env.atcoderUsername}}/rating/heuristic 114 | method: GET 115 | check: 116 | status: 200 117 | headers: 118 | content-type: image/svg+xml 119 | 120 | bluesky: 121 | steps: 122 | - name: Bluesky - Followers 123 | http: 124 | url: /img/bluesky/${{env.blueskyUsername}}/followers 125 | method: GET 126 | check: 127 | status: 200 128 | headers: 129 | content-type: image/svg+xml 130 | - name: Bluesky - Posts 131 | http: 132 | url: /img/bluesky/${{env.blueskyUsername}}/posts 133 | method: GET 134 | check: 135 | status: 200 136 | headers: 137 | content-type: image/svg+xml 138 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | output: "standalone", 6 | webpack: (config) => { 7 | config.module.rules.push( 8 | ...[ 9 | { 10 | test: /\.svg$/, 11 | use: ["@svgr/webpack"], 12 | }, 13 | ] 14 | ); 15 | return config; 16 | }, 17 | async rewrites() { 18 | return [ 19 | { 20 | source: "/img/:path*", 21 | destination: "/api/:path*", 22 | }, 23 | ]; 24 | }, 25 | }; 26 | 27 | module.exports = nextConfig; 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "license": "MIT", 4 | "private": true, 5 | "engines": { 6 | "node": "16.20.2" 7 | }, 8 | "scripts": { 9 | "dev": "next dev", 10 | "build:logos": "ts-node ./src/tasks/buildLogos.ts", 11 | "prebuild": "npm run build:logos", 12 | "build": "next build", 13 | "start": "next start", 14 | "lint": "next lint", 15 | "e2e": "stepci run e2e/workflow.yml" 16 | }, 17 | "dependencies": { 18 | "@atproto/api": "0.9.7", 19 | "@headlessui/react": "1.7.15", 20 | "axios": "0.27.2", 21 | "badge-maker": "3.3.1", 22 | "cheerio": "1.0.0-rc.12", 23 | "classnames": "2.3.2", 24 | "copy-to-clipboard": "3.3.2", 25 | "date-fns": "2.29.3", 26 | "firebase-admin": "11.0.1", 27 | "next": "12.3.1", 28 | "react": "18.2.0", 29 | "react-dom": "18.2.0", 30 | "react-icons": "4.4.0", 31 | "react-scroll": "1.8.7", 32 | "winston": "3.8.2" 33 | }, 34 | "devDependencies": { 35 | "@svgr/webpack": "6.3.1", 36 | "@types/gtag.js": "0.0.20", 37 | "@types/node": "18.7.23", 38 | "@types/react": "18.0.21", 39 | "@types/react-dom": "18.0.6", 40 | "@types/react-scroll": "1.8.4", 41 | "@typescript-eslint/eslint-plugin": "5.62.0", 42 | "autoprefixer": "10.4.12", 43 | "eslint": "8.24.0", 44 | "eslint-config-next": "12.3.7", 45 | "eslint-config-prettier": "8.5.0", 46 | "eslint-plugin-unused-imports": "2.0.0", 47 | "postcss": "8.4.16", 48 | "prettier": "2.7.1", 49 | "prettier-plugin-tailwindcss": "0.1.13", 50 | "stepci": "2.5.6", 51 | "tailwindcss": "3.1.8", 52 | "ts-node": "10.9.1", 53 | "typescript": "4.8.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/badge-generator/8898691afe3562724fbfd0659c74203c9ead3c09/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 13 | 14 | 79 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /frontend/public/logos/atcoder_black.svg: -------------------------------------------------------------------------------- 1 | logo 2 | -------------------------------------------------------------------------------- /frontend/public/logos/bluesky.svg: -------------------------------------------------------------------------------- 1 | 2 | Bluesky 3 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/logos/qiita.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/badge-generator/8898691afe3562724fbfd0659c74203c9ead3c09/frontend/public/logos/qiita.png -------------------------------------------------------------------------------- /frontend/public/logos/zenn.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/api/api.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from "@/lib/api/api"; 2 | import { BadgeStyle } from "@/lib/badge"; 3 | import { renderBadge } from "@/lib/renderBadge"; 4 | import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; 5 | 6 | export type Query = { 7 | username: string; 8 | style?: BadgeStyle; 9 | label?: string; 10 | }; 11 | 12 | export type Options = { logo: string; label: string } & ( 13 | | { 14 | error: ApiError; 15 | } 16 | | { color: string; message: string; error?: undefined } 17 | ); 18 | 19 | const _selectStyle = (style?: BadgeStyle): BadgeStyle => { 20 | const defaultStyle = BadgeStyle.plastic; 21 | if (!style) return defaultStyle; 22 | if (Object.values(BadgeStyle).includes(style)) return style; 23 | return defaultStyle; 24 | }; 25 | 26 | const _selectErrorMessage = (error: ApiError): string => { 27 | switch (error) { 28 | case ApiError.UserNotFound: 29 | return "user not found"; 30 | case ApiError.DataNotFound: 31 | return "data not found"; 32 | case ApiError.RateLimit: 33 | return "service temporarily unavailable"; 34 | } 35 | }; 36 | 37 | export const renderSvg = 38 | (queryToRenderOptions: (query: Query) => Promise): NextApiHandler => 39 | async (req: NextApiRequest, res: NextApiResponse) => { 40 | const query = req.query as Query; 41 | const options = await queryToRenderOptions(query); 42 | 43 | const badgeOptions = (() => { 44 | const base = { 45 | logoDataUrl: options.logo, 46 | label: query.label?.trim() || options.label, 47 | style: _selectStyle(query.style), 48 | }; 49 | 50 | if (options.error) { 51 | return { 52 | ...base, 53 | color: "#D1654D", 54 | message: _selectErrorMessage(options.error), 55 | }; 56 | } 57 | return { ...base, color: options.color, message: options.message }; 58 | })(); 59 | 60 | const svg = renderBadge({ 61 | ...badgeOptions, 62 | }); 63 | 64 | const status = (() => { 65 | switch (options.error) { 66 | case ApiError.UserNotFound: 67 | case ApiError.DataNotFound: 68 | return 404; 69 | case ApiError.RateLimit: 70 | return 503; 71 | default: 72 | return 200; 73 | } 74 | })(); 75 | 76 | return res 77 | .status(status) 78 | .setHeader("content-type", "image/svg+xml") 79 | .send(svg); 80 | }; 81 | -------------------------------------------------------------------------------- /frontend/src/api/atcoder.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from "next"; 2 | import { renderSvg } from "@/api/api"; 3 | import { getAlgorithmRating, getHeuristicRating } from "@/lib/api/atcoderApi"; 4 | import logos from "@/logos.json"; 5 | 6 | export type AtCoderBadgeType = "algorithm_rating" | "heuristic_rating"; 7 | 8 | const _selectLabel = (type: AtCoderBadgeType): string => 9 | ({ 10 | algorithm_rating: "Rating", 11 | heuristic_rating: "Rating(Heuristic)", 12 | }[type]); 13 | 14 | const _selectColor = (rating: number): string => { 15 | if (rating >= 2800) return "#ff0000"; // 赤 16 | if (rating >= 2400) return "#ff8c00"; // 橙 17 | if (rating >= 2000) return "#ffff00"; // 黄 18 | if (rating >= 1600) return "#4169e1"; // 青 19 | if (rating >= 1200) return "#57BFC0"; // 水 20 | if (rating >= 800) return "#7cfc00"; // 緑 21 | if (rating >= 400) return "#b8860b"; // 茶 22 | return "#808080"; // 灰 23 | }; 24 | 25 | const _handler = (type: AtCoderBadgeType): NextApiHandler => 26 | renderSvg(async (query) => { 27 | const logo = 28 | query.style === "social" ? logos.atcoderBlack : logos.atcoderWhite; 29 | 30 | const base = { 31 | logo, 32 | label: _selectLabel(type), 33 | }; 34 | 35 | const result = await { 36 | algorithm_rating: getAlgorithmRating, 37 | heuristic_rating: getHeuristicRating, 38 | }[type](query.username); 39 | if (result.error) return { ...base, error: result.error }; 40 | 41 | return { 42 | ...base, 43 | color: _selectColor(result.data), 44 | message: result.data.toString(), 45 | }; 46 | }); 47 | 48 | export const algorithmRating = _handler("algorithm_rating"); 49 | export const heuristicRating = _handler("heuristic_rating"); 50 | -------------------------------------------------------------------------------- /frontend/src/api/bluesky.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from "next"; 2 | import { renderSvg } from "@/api/api"; 3 | import { getProfile } from "@/lib/api/bluesky"; 4 | import logos from "@/logos.json"; 5 | 6 | export type BlueskyBadgeType = "followers" | "posts"; 7 | 8 | const _selectLabel = (type: BlueskyBadgeType): string => 9 | ({ 10 | followers: "Followers", 11 | posts: "Posts", 12 | }[type]); 13 | 14 | const _handler = (type: BlueskyBadgeType): NextApiHandler => 15 | renderSvg(async (query) => { 16 | const base = { 17 | logo: logos.bluesky, 18 | color: "#0285FF", 19 | label: _selectLabel(type), 20 | }; 21 | 22 | const result = await getProfile(query.username); 23 | if (result.error) return { ...base, error: result.error }; 24 | 25 | const value = (() => { 26 | switch (type) { 27 | case "followers": 28 | return result.data.followersCount ?? 0; 29 | case "posts": 30 | return result.data.postsCount ?? 0; 31 | } 32 | })(); 33 | 34 | return { 35 | ...base, 36 | message: value.toString(), 37 | }; 38 | }); 39 | 40 | export const followers = _handler("followers"); 41 | export const posts = _handler("posts"); 42 | -------------------------------------------------------------------------------- /frontend/src/api/qiita.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from "next"; 2 | import { renderSvg } from "@/api/api"; 3 | import { getContributions, getUser } from "@/lib/api/qiitaApi"; 4 | import logos from "@/logos.json"; 5 | 6 | export type QiitaBadgeType = "contributions" | "followers" | "articles"; 7 | 8 | const _selectLabel = (type: QiitaBadgeType): string => 9 | ({ 10 | articles: "Articles", 11 | followers: "Followers", 12 | contributions: "Contributions", 13 | }[type]); 14 | 15 | const _handler = (type: QiitaBadgeType): NextApiHandler => 16 | renderSvg(async (query) => { 17 | const base = { 18 | logo: logos.qiita, 19 | color: "#55C500", 20 | label: _selectLabel(type), 21 | }; 22 | 23 | if (type === "contributions") { 24 | const result = await getContributions(query.username); 25 | if (result.error) return { ...base, error: result.error }; 26 | return { ...base, message: result.data.toString() }; 27 | } 28 | 29 | const result = await getUser(query.username); 30 | if (result.error) return { ...base, error: result.error }; 31 | 32 | const value = (() => { 33 | switch (type) { 34 | case "articles": 35 | return result.data.items_count; 36 | case "followers": 37 | return result.data.followers_count; 38 | } 39 | })(); 40 | 41 | return { 42 | ...base, 43 | message: value.toString(), 44 | }; 45 | }); 46 | 47 | export const articles = _handler("articles"); 48 | export const followers = _handler("followers"); 49 | export const contributions = _handler("contributions"); 50 | -------------------------------------------------------------------------------- /frontend/src/api/zenn.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from "next"; 2 | import { renderSvg } from "@/api/api"; 3 | import { getUser } from "@/lib/api/zennApi"; 4 | import logos from "@/logos.json"; 5 | 6 | export type ZennBadgeType = 7 | | "articles" 8 | | "books" 9 | | "followers" 10 | | "scraps" 11 | | "likes"; 12 | 13 | const _selectLabel = (type: ZennBadgeType): string => 14 | ({ 15 | articles: "Articles", 16 | books: "Books", 17 | followers: "Followers", 18 | scraps: "Scraps", 19 | likes: "Likes", 20 | }[type]); 21 | 22 | const _handler = (type: ZennBadgeType): NextApiHandler => 23 | renderSvg(async (query) => { 24 | const base = { 25 | logo: logos.zenn, 26 | color: "#3EA8FF", 27 | label: _selectLabel(type), 28 | }; 29 | 30 | const result = await getUser(query.username); 31 | if (result.error) return { ...base, error: result.error }; 32 | 33 | const value = { 34 | articles: result.data.articles_count, 35 | books: result.data.books_count, 36 | followers: result.data.follower_count, 37 | likes: result.data.total_liked_count, 38 | scraps: result.data.scraps_count, 39 | }[type]; 40 | 41 | return { 42 | ...base, 43 | message: value.toString(), 44 | }; 45 | }); 46 | 47 | export const articles = _handler("articles"); 48 | export const books = _handler("books"); 49 | export const followers = _handler("followers"); 50 | export const likes = _handler("likes"); 51 | export const scraps = _handler("scraps"); 52 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import LayoutFooter from "@/components/Layout/LayoutFooter"; 3 | import LayoutHeader from "@/components/Layout/LayoutHeader"; 4 | 5 | export type LayoutProps = { 6 | children: React.ReactNode; 7 | }; 8 | 9 | const Layout: React.FC = memo((props) => { 10 | const { children } = props; 11 | 12 | return ( 13 |
14 | 15 | 16 |
17 |
{children}
18 |
19 | 20 | 21 |
22 | ); 23 | }); 24 | 25 | Layout.displayName = "Layout"; 26 | 27 | export default Layout; 28 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/LayoutFooter.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import { BsGithub } from "react-icons/bs"; 3 | import Link from "@/components/util/Link"; 4 | 5 | const LayoutFooter: React.FC = memo(() => { 6 | return ( 7 |
8 |
9 |
    10 |
  • 11 |

    ©2022 Koki Sato

    12 |
  • 13 |
  • 14 | プライバシーポリシー 15 |
  • 16 |
  • 17 | 22 | 23 | 24 |
  • 25 |
26 |
27 |
28 | ); 29 | }); 30 | 31 | LayoutFooter.displayName = "LayoutFooter"; 32 | 33 | export default LayoutFooter; 34 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/LayoutHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import Logo from "@/../public/logo.svg"; 3 | import Link from "@/components/util/Link"; 4 | 5 | const LayoutHeader: React.FC = memo(() => { 6 | return ( 7 |
8 |
9 |

10 | 11 | 12 | Badge Generator 13 | 14 |

15 |
16 |
17 | ); 18 | }); 19 | 20 | LayoutHeader.displayName = "LayoutHeader"; 21 | 22 | export default LayoutHeader; 23 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | import Layout from "./Layout"; 2 | 3 | export default Layout; 4 | -------------------------------------------------------------------------------- /frontend/src/components/pages/HomePage/BadgeBlocks.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; 3 | import { GoLinkExternal } from "react-icons/go"; 4 | import * as Scroll from "react-scroll"; 5 | import BadgeBlock, { Badge } from "@/components/util/BadgeBlock"; 6 | import Input from "@/components/util/Input"; 7 | import Link from "@/components/util/Link"; 8 | import { BadgeStyle } from "@/lib/badge"; 9 | 10 | export type BadgeBlocksProps = { 11 | title: string; 12 | logo: string; 13 | serviceUrl: string; 14 | defaultUsername: string; 15 | usernameToBadges: (username: string) => Badge[]; 16 | }; 17 | 18 | const BadgeBlocks: React.FC = memo((props) => { 19 | const { title, logo, serviceUrl, defaultUsername, usernameToBadges } = props; 20 | 21 | const [username, setUsername] = useState(""); 22 | const [style, setStyle] = useState(BadgeStyle.plastic); 23 | const [badges, setBadges] = useState( 24 | usernameToBadges(defaultUsername) 25 | ); 26 | 27 | const handleChangeUsername = useCallback( 28 | (event: React.ChangeEvent) => { 29 | setUsername(event.currentTarget.value); 30 | }, 31 | [] 32 | ); 33 | 34 | const handleChangeStyle = useCallback( 35 | (event: React.ChangeEvent) => { 36 | setStyle(event.currentTarget.value as BadgeStyle); 37 | }, 38 | [] 39 | ); 40 | 41 | const badgeUsername = useMemo(() => { 42 | return username.trim() || defaultUsername; 43 | }, [defaultUsername, username]); 44 | 45 | useEffect(() => { 46 | setBadges(usernameToBadges(badgeUsername)); 47 | }, [badgeUsername, usernameToBadges]); 48 | 49 | return ( 50 |
51 | 52 |

53 | 58 | 59 | {title} 60 | 61 | 62 |

63 | 64 |
65 | 74 | 75 | ({ 81 | text: style, 82 | value: style, 83 | }))} 84 | value={style} 85 | onChange={handleChangeStyle} 86 | /> 87 |
88 | 89 | {badges.map((badge) => ( 90 |
91 | 92 |
93 | ))} 94 |
95 | ); 96 | }); 97 | 98 | BadgeBlocks.displayName = "BadgeBlocks"; 99 | 100 | export default BadgeBlocks; 101 | -------------------------------------------------------------------------------- /frontend/src/components/pages/HomePage/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import React from "react"; 3 | import BadgeBlocks from "@/components/pages/HomePage/BadgeBlocks"; 4 | import ServiceCard from "@/components/pages/HomePage/ServiceCard"; 5 | import { Badge } from "@/components/util/BadgeBlock"; 6 | import Divider from "@/components/util/Divider"; 7 | import { 8 | buildAtCoderBadgeUrl, 9 | buildBlueskyBadgeUrl, 10 | buildQiitaBadgeUrl, 11 | buildZennBadgeUrl, 12 | } from "@/lib/badgeUrl"; 13 | import logos from "@/logos.json"; 14 | import type { NextPage } from "next"; 15 | 16 | const usernameToZennBadges = (username: string): Badge[] => { 17 | return [ 18 | { 19 | name: "Likes", 20 | buildUrl: buildZennBadgeUrl("likes"), 21 | link: `https://zenn.dev/${username}`, 22 | }, 23 | { 24 | name: "Followers", 25 | buildUrl: buildZennBadgeUrl("followers"), 26 | link: `https://zenn.dev/${username}`, 27 | }, 28 | { 29 | name: "Articles", 30 | buildUrl: buildZennBadgeUrl("articles"), 31 | link: `https://zenn.dev/${username}`, 32 | }, 33 | { 34 | name: "Books", 35 | buildUrl: buildZennBadgeUrl("books"), 36 | link: `https://zenn.dev/${username}?tab=books`, 37 | }, 38 | { 39 | name: "Scraps", 40 | buildUrl: buildZennBadgeUrl("scraps"), 41 | link: `https://zenn.dev/${username}?tab=scraps`, 42 | }, 43 | ]; 44 | }; 45 | 46 | const usernameToQiitaBadges = (username: string): Badge[] => { 47 | return [ 48 | { 49 | name: "Contributions", 50 | buildUrl: buildQiitaBadgeUrl("contributions"), 51 | link: `https://qiita.com/${username}`, 52 | }, 53 | { 54 | name: "Followers", 55 | buildUrl: buildQiitaBadgeUrl("followers"), 56 | link: `https://qiita.com/${username}`, 57 | }, 58 | { 59 | name: "Articles", 60 | buildUrl: buildQiitaBadgeUrl("articles"), 61 | link: `https://qiita.com/${username}`, 62 | }, 63 | ]; 64 | }; 65 | 66 | const usernameToAtCoderBadge = (username: string): Badge[] => { 67 | return [ 68 | { 69 | name: "Rating", 70 | buildUrl: buildAtCoderBadgeUrl("algorithm_rating"), 71 | link: `https://atcoder.jp/users/${username}?contestType=algo`, 72 | }, 73 | { 74 | name: "Rating(Heuristic)", 75 | buildUrl: buildAtCoderBadgeUrl("heuristic_rating"), 76 | link: `https://atcoder.jp/users/${username}?contestType=heuristic`, 77 | }, 78 | ]; 79 | }; 80 | 81 | const usernameToBlueskyBadges = (username: string): Badge[] => { 82 | return [ 83 | { 84 | name: "Followers", 85 | buildUrl: buildBlueskyBadgeUrl("followers"), 86 | link: `https://bsky.app/profile/${username}`, 87 | }, 88 | { 89 | name: "Posts", 90 | buildUrl: buildBlueskyBadgeUrl("posts"), 91 | link: `https://bsky.app/profile/${username}`, 92 | }, 93 | ]; 94 | }; 95 | 96 | const cards = [ 97 | { name: "Zenn", imgSrc: logos.zenn }, 98 | { name: "Qiita", imgSrc: logos.qiita }, 99 | { name: "AtCoder", imgSrc: logos.atcoderBlack }, 100 | { name: "Bluesky", imgSrc: logos.bluesky }, 101 | ]; 102 | 103 | const HomePage: NextPage = () => { 104 | return ( 105 |
106 | 107 | Badge Generator 108 | 109 | 110 | 111 |
112 | {cards.map((card) => ( 113 | 114 | ))} 115 |
116 | 117 | 118 | 119 | 126 | 127 | 128 | 129 | 136 | 137 | 138 | 139 | 146 | 147 | 148 | 149 | 156 |
157 | ); 158 | }; 159 | 160 | export default HomePage; 161 | -------------------------------------------------------------------------------- /frontend/src/components/pages/HomePage/ServiceCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import React, { memo } from "react"; 3 | import * as Scroll from "react-scroll"; 4 | 5 | export type ServiceCardProps = { 6 | name: string; 7 | imgSrc: string; 8 | }; 9 | 10 | const ServiceCard: React.FC = memo((props) => { 11 | const { name, imgSrc } = props; 12 | return ( 13 | 19 | 20 | {name} 21 | 22 | ); 23 | }); 24 | 25 | ServiceCard.displayName = "ServiceCard"; 26 | 27 | export default ServiceCard; 28 | -------------------------------------------------------------------------------- /frontend/src/components/pages/HomePage/index.ts: -------------------------------------------------------------------------------- 1 | import HomePage from "./HomePage"; 2 | 3 | export default HomePage; 4 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PrivacyPolicyPage/PrivacyPolicyItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | 3 | export type PrivacyPolicyItemProps = { 4 | title: string; 5 | children: React.ReactNode; 6 | }; 7 | 8 | const PrivacyPolicyItem: React.FC = memo((props) => { 9 | const { title, children } = props; 10 | 11 | return ( 12 |
13 |

{title}

14 |

{children}

15 |
16 | ); 17 | }); 18 | 19 | PrivacyPolicyItem.displayName = "PrivacyPolicyItem"; 20 | 21 | export default PrivacyPolicyItem; 22 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PrivacyPolicyPage/PrivacyPolicyPage.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import Head from "next/head"; 3 | import React from "react"; 4 | import PrivacyPolicyItem from "@/components/pages/PrivacyPolicyPage/PrivacyPolicyItem"; 5 | import Link from "@/components/util/Link"; 6 | 7 | const PrivacyPolicyPage: NextPage = () => { 8 | return ( 9 |
10 | 11 | プライバシーポリシー | Badge Generator 12 | 13 | 14 |
15 | 16 | 当サイトでは、 Google によるアクセス解析ツール「 Google 17 | アナリティクス」を利用しています。この Google 18 | アナリティクスはトラフィックデータの収集のために Cookie 19 | を使用しています。このトラフィックデータは匿名で収集されており、個人を特定するものではありません。この機能は 20 | Cookie 21 | を無効にすることで収集を拒否することが出来ますので、お使いのブラウザの設定をご確認ください。この規約に関して、詳しくは{" "} 22 | 27 | Google アナリティクス利用規約 28 | {" "} 29 | を参照してください。 30 | 31 | 32 | 当サイトは、個人情報に関して適用される日本の法令を遵守するとともに、本ポリシーの内容を適宜見直しその改善に努めます。修正された最新のプライバシーポリシーは常に本ページにて開示されます。 33 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | PrivacyPolicyPage.displayName = "PrivacyPolicyPage"; 40 | 41 | export default PrivacyPolicyPage; 42 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PrivacyPolicyPage/index.ts: -------------------------------------------------------------------------------- 1 | import PrivacyPolicyPage from "./PrivacyPolicyPage"; 2 | 3 | export default PrivacyPolicyPage; 4 | -------------------------------------------------------------------------------- /frontend/src/components/util/BadgeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; 2 | import { Query } from "@/api/api"; 3 | import Disclosure from "@/components/util/Disclosure"; 4 | import Input from "@/components/util/Input"; 5 | import { BadgeStyle } from "@/lib/badge"; 6 | 7 | export type Badge = { 8 | name: string; 9 | buildUrl: (options: Query) => string; 10 | link: string; 11 | }; 12 | 13 | export type BadgeBlockProps = { 14 | badge: Badge; 15 | username: string; 16 | style: BadgeStyle; 17 | }; 18 | 19 | const BadgeBlock: React.FC = memo((props) => { 20 | const { badge, username, style } = props; 21 | 22 | const [label, setLabel] = useState(""); 23 | const [badgeSrc, setBadgeSrc] = useState( 24 | badge.buildUrl({ username, style, label }) 25 | ); 26 | 27 | const inputs = useMemo(() => { 28 | return [ 29 | { 30 | label: "Markdown", 31 | value: `[![${badge.name}](${badgeSrc})](${badge.link})`, 32 | }, 33 | { 34 | label: "HTML", 35 | value: `${badge.name}`, 36 | }, 37 | { 38 | label: "URL", 39 | value: badgeSrc, 40 | }, 41 | ]; 42 | }, [badge.link, badge.name, badgeSrc]); 43 | 44 | const handleChangeLabel = useCallback( 45 | (event: React.ChangeEvent) => { 46 | setLabel(event.currentTarget.value); 47 | }, 48 | [] 49 | ); 50 | 51 | useEffect(() => { 52 | const timeoutId = setTimeout(() => { 53 | setBadgeSrc(badge.buildUrl({ username, style, label })); 54 | }, 500); 55 | return () => { 56 | clearTimeout(timeoutId); 57 | }; 58 | // eslint-disable-next-line react-hooks/exhaustive-deps 59 | }, [badge, label, username]); 60 | 61 | // style の変更だけ即時反映させる 62 | useEffect(() => { 63 | setBadgeSrc(badge.buildUrl({ username, style, label })); 64 | // eslint-disable-next-line react-hooks/exhaustive-deps 65 | }, [style]); 66 | 67 | return ( 68 | 71 |

{badge.name}

72 | 73 | {/* eslint-disable-next-line @next/next/no-img-element */} 74 | Badge 75 | 76 | 77 | } 78 | > 79 |
80 | 89 | 90 | {inputs.map((input) => ( 91 | 100 | ))} 101 |
102 |
103 | ); 104 | }); 105 | 106 | BadgeBlock.displayName = "BadgeBlock"; 107 | 108 | export default BadgeBlock; 109 | -------------------------------------------------------------------------------- /frontend/src/components/util/Disclosure.tsx: -------------------------------------------------------------------------------- 1 | import { Disclosure as HeadlessDisclosure } from "@headlessui/react"; 2 | import classNames from "classnames"; 3 | import React, { memo } from "react"; 4 | import { BsChevronDown, BsChevronUp } from "react-icons/bs"; 5 | 6 | export type DisclosureProps = { 7 | button: React.ReactNode; 8 | children: React.ReactNode; 9 | }; 10 | 11 | const Disclosure: React.FC = memo((props) => { 12 | const { button, children } = props; 13 | 14 | return ( 15 | 16 | {({ open }) => ( 17 | <> 18 | 24 | 25 | {open && } 26 | {!open && } 27 | 28 | {button} 29 | 30 | 31 | {children} 32 | 33 | 34 | )} 35 | 36 | ); 37 | }); 38 | 39 | Disclosure.displayName = "Disclosure"; 40 | 41 | export default Disclosure; 42 | -------------------------------------------------------------------------------- /frontend/src/components/util/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | 3 | const Divider: React.FC = memo(() => { 4 | return
; 5 | }); 6 | 7 | Divider.displayName = "Divider"; 8 | 9 | export default Divider; 10 | -------------------------------------------------------------------------------- /frontend/src/components/util/Input.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import copy from "copy-to-clipboard"; 3 | import React, { memo, useCallback, useEffect, useState } from "react"; 4 | import { AiOutlineCheck, AiOutlineCopy } from "react-icons/ai"; 5 | 6 | type BaseProps = { 7 | inputClassname?: string; 8 | label: string; 9 | fullWidth?: boolean; 10 | withCopy?: boolean; 11 | }; 12 | 13 | type TextProps = Omit, "type"> & { 14 | type: "text"; 15 | }; 16 | 17 | type SelectProps = Omit, "children"> & { 18 | type: "select"; 19 | options: Option[]; 20 | }; 21 | 22 | type Option = { 23 | text: string; 24 | value: string; 25 | }; 26 | 27 | export type InputProps = BaseProps & (TextProps | SelectProps); 28 | 29 | const Select: React.FC = memo((props) => { 30 | const { type: _, options, ...selectProps } = props; 31 | 32 | return ( 33 | 46 | ); 47 | }); 48 | 49 | Select.displayName = "Select"; 50 | 51 | const Input: React.FC = memo((props) => { 52 | const { 53 | inputClassname, 54 | label, 55 | fullWidth, 56 | withCopy, 57 | className, 58 | ...inputProps 59 | } = props; 60 | 61 | const [copied, setCopied] = useState(false); 62 | 63 | const handleCopy = useCallback(() => { 64 | const value = inputProps.value?.toString() ?? ""; 65 | if (copy(value)) { 66 | setCopied(true); 67 | } 68 | }, [inputProps.value]); 69 | 70 | useEffect(() => { 71 | if (!copied) return; 72 | 73 | const timeoutId = setTimeout(() => { 74 | setCopied(false); 75 | }, 1000); 76 | return () => { 77 | clearTimeout(timeoutId); 78 | }; 79 | }, [copied]); 80 | 81 | return ( 82 |
83 | 84 | 85 | {withCopy && ( 86 | 96 | )} 97 | {inputProps.type == "text" && ( 98 | 110 | )} 111 | {inputProps.type == "select" && ( 112 |