├── .gitignore ├── frontend ├── bunfig.toml ├── src │ ├── styles │ │ └── global.css │ ├── components │ │ ├── Layout │ │ │ ├── index.ts │ │ │ ├── LayoutHeader.tsx │ │ │ ├── Layout.tsx │ │ │ └── LayoutFooter.tsx │ │ ├── pages │ │ │ ├── HomePage │ │ │ │ ├── index.ts │ │ │ │ ├── ServiceCard.tsx │ │ │ │ ├── BadgeBlocks.tsx │ │ │ │ └── HomePage.tsx │ │ │ └── PrivacyPolicyPage │ │ │ │ ├── index.ts │ │ │ │ ├── PrivacyPolicyItem.tsx │ │ │ │ └── PrivacyPolicyPage.tsx │ │ └── util │ │ │ ├── Divider.tsx │ │ │ ├── Link.tsx │ │ │ ├── Disclosure.tsx │ │ │ ├── BadgeBlock.tsx │ │ │ └── Input.tsx │ ├── pages │ │ ├── index.tsx │ │ ├── privacy.tsx │ │ ├── api │ │ │ ├── zenn │ │ │ │ └── [username] │ │ │ │ │ ├── books.ts │ │ │ │ │ ├── likes.ts │ │ │ │ │ ├── scraps.ts │ │ │ │ │ ├── articles.ts │ │ │ │ │ └── followers.ts │ │ │ ├── bluesky │ │ │ │ └── [username] │ │ │ │ │ ├── posts.ts │ │ │ │ │ └── followers.ts │ │ │ ├── qiita │ │ │ │ └── [username] │ │ │ │ │ ├── articles.ts │ │ │ │ │ ├── followers.ts │ │ │ │ │ └── contributions.ts │ │ │ └── atcoder │ │ │ │ └── [username] │ │ │ │ └── rating │ │ │ │ ├── algorithm.ts │ │ │ │ └── heuristic.ts │ │ ├── _app.tsx │ │ └── _document.tsx │ ├── lib │ │ ├── logger.ts │ │ ├── api │ │ │ ├── firestore.ts │ │ │ ├── api.ts │ │ │ ├── axios.ts │ │ │ ├── zennApi.ts │ │ │ ├── bluesky.ts │ │ │ ├── atcoderApi.ts │ │ │ ├── cache.ts │ │ │ ├── rate.ts │ │ │ └── qiitaApi.ts │ │ ├── renderBadge.ts │ │ ├── badge.ts │ │ └── badgeUrl.ts │ ├── index.d.ts │ ├── tasks │ │ └── buildLogos.ts │ └── api │ │ ├── bluesky.ts │ │ ├── zenn.ts │ │ ├── qiita.ts │ │ ├── atcoder.ts │ │ └── api.ts ├── .prettierrc ├── .dockerignore ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── logos │ │ ├── qiita.png │ │ ├── zenn.svg │ │ ├── bluesky.svg │ │ └── atcoder_black.svg │ └── logo.svg ├── .gitignore ├── next.config.js ├── tsconfig.json ├── .eslintrc.js ├── Dockerfile ├── package.json └── e2e │ └── workflow.yml ├── terraform ├── app │ ├── project.tf │ ├── locals.tf │ ├── provider.tf │ ├── backend.tf │ ├── app_engine.tf │ ├── artifact_registry.tf │ ├── versions.tf │ ├── sa.tf │ ├── cloud_run.tf │ └── .terraform.lock.hcl ├── github-actions │ ├── project.tf │ ├── locals.tf │ ├── provider.tf │ ├── backend.tf │ ├── versions.tf │ ├── outputs.tf │ ├── sa.tf │ ├── workload_identity.tf │ └── .terraform.lock.hcl └── .gitignore ├── mise.toml ├── .envrc.template ├── renovate.json ├── .github ├── workflows │ ├── e2e.yml │ ├── ci.yml │ ├── _ci.yml │ ├── _e2e.yml │ └── release-please.yml └── actions │ └── setup-frontend │ └── action.yml ├── .claude └── settings.json ├── LICENSE ├── CLAUDE.md ├── CHANGELOG.md ├── bin └── ctrl.sh └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | -------------------------------------------------------------------------------- /frontend/bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | exact = true 3 | -------------------------------------------------------------------------------- /frontend/src/styles/global.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /terraform/app/project.tf: -------------------------------------------------------------------------------- 1 | data "google_project" "main" {} 2 | -------------------------------------------------------------------------------- /terraform/github-actions/project.tf: -------------------------------------------------------------------------------- 1 | data "google_project" "main" {} 2 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | bun = "1.2.16" 3 | node = "18.20.8" 4 | terraform = "1.5.7" 5 | -------------------------------------------------------------------------------- /terraform/github-actions/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | region = "asia-northeast1" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | import Layout from "./Layout"; 2 | 3 | export default Layout; 4 | -------------------------------------------------------------------------------- /terraform/app/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | prefix = "badge-generator" 3 | region = "asia-northeast1" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | .next 5 | .git 6 | .env.local.template 7 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/badge-generator/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from "@/components/pages/HomePage"; 2 | 3 | export default HomePage; 4 | -------------------------------------------------------------------------------- /terraform/app/provider.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = "badge-generator" 3 | region = local.region 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/pages/HomePage/index.ts: -------------------------------------------------------------------------------- 1 | import HomePage from "./HomePage"; 2 | 3 | export default HomePage; 4 | -------------------------------------------------------------------------------- /terraform/app/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "gcs" { 3 | bucket = "badge-generator-tfstates" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/public/logos/qiita.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/badge-generator/HEAD/frontend/public/logos/qiita.png -------------------------------------------------------------------------------- /terraform/github-actions/provider.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = "badge-generator" 3 | region = local.region 4 | } 5 | -------------------------------------------------------------------------------- /terraform/github-actions/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "gcs" { 3 | bucket = "badge-generator-gh-actions-tfstates" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.envrc.template: -------------------------------------------------------------------------------- 1 | export QIITA_ACCESS_TOKEN="" 2 | export BLUESKY_IDENTIFIER="" 3 | export BLUESKY_PASSWORD="" 4 | export GA_MEASUREMENT_ID="" 5 | -------------------------------------------------------------------------------- /frontend/src/pages/privacy.tsx: -------------------------------------------------------------------------------- 1 | import PrivacyPolicyPage from "@/components/pages/PrivacyPolicyPage"; 2 | 3 | export default PrivacyPolicyPage; 4 | -------------------------------------------------------------------------------- /frontend/src/pages/api/zenn/[username]/books.ts: -------------------------------------------------------------------------------- 1 | import { books } from "@/api/zenn"; 2 | 3 | const handler = books; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/zenn/[username]/likes.ts: -------------------------------------------------------------------------------- 1 | import { likes } from "@/api/zenn"; 2 | 3 | const handler = likes; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>koki-develop/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PrivacyPolicyPage/index.ts: -------------------------------------------------------------------------------- 1 | import PrivacyPolicyPage from "./PrivacyPolicyPage"; 2 | 3 | export default PrivacyPolicyPage; 4 | -------------------------------------------------------------------------------- /frontend/src/pages/api/bluesky/[username]/posts.ts: -------------------------------------------------------------------------------- 1 | import { posts } from "@/api/bluesky"; 2 | 3 | const handler = posts; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/zenn/[username]/scraps.ts: -------------------------------------------------------------------------------- 1 | import { scraps } from "@/api/zenn"; 2 | 3 | const handler = scraps; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/qiita/[username]/articles.ts: -------------------------------------------------------------------------------- 1 | import { articles } from "@/api/qiita"; 2 | 3 | const handler = articles; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/zenn/[username]/articles.ts: -------------------------------------------------------------------------------- 1 | import { articles } from "@/api/zenn"; 2 | 3 | const handler = articles; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /terraform/app/app_engine.tf: -------------------------------------------------------------------------------- 1 | resource "google_app_engine_application" "main" { 2 | location_id = local.region 3 | database_type = "CLOUD_FIRESTORE" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/pages/api/qiita/[username]/followers.ts: -------------------------------------------------------------------------------- 1 | import { followers } from "@/api/qiita"; 2 | 3 | const handler = followers; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/zenn/[username]/followers.ts: -------------------------------------------------------------------------------- 1 | import { followers } from "@/api/zenn"; 2 | 3 | const handler = followers; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/bluesky/[username]/followers.ts: -------------------------------------------------------------------------------- 1 | import { followers } from "@/api/bluesky"; 2 | 3 | const handler = followers; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/qiita/[username]/contributions.ts: -------------------------------------------------------------------------------- 1 | import { contributions } from "@/api/qiita"; 2 | 3 | const handler = contributions; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /frontend/src/pages/api/atcoder/[username]/rating/algorithm.ts: -------------------------------------------------------------------------------- 1 | import { algorithmRating } from "@/api/atcoder"; 2 | 3 | const handler = algorithmRating; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/api/atcoder/[username]/rating/heuristic.ts: -------------------------------------------------------------------------------- 1 | import { heuristicRating } from "@/api/atcoder"; 2 | 3 | const handler = heuristicRating; 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /frontend/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from "winston"; 2 | 3 | export const logger = winston.createLogger({ 4 | level: "info", 5 | transports: [new winston.transports.Console()], 6 | }); 7 | -------------------------------------------------------------------------------- /terraform/app/artifact_registry.tf: -------------------------------------------------------------------------------- 1 | resource "google_artifact_registry_repository" "main" { 2 | location = local.region 3 | repository_id = "app" 4 | format = "DOCKER" 5 | } 6 | -------------------------------------------------------------------------------- /terraform/app/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "1.5.7" 3 | 4 | required_providers { 5 | google = { 6 | source = "hashicorp/google" 7 | version = "4.38.0" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /terraform/github-actions/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "1.5.7" 3 | 4 | required_providers { 5 | google = { 6 | source = "hashicorp/google" 7 | version = "4.38.0" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /terraform/github-actions/outputs.tf: -------------------------------------------------------------------------------- 1 | output "workload_identity_provider_name" { 2 | value = google_iam_workload_identity_pool_provider.main.name 3 | } 4 | 5 | output "service_account_email" { 6 | value = google_service_account.main.email 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/lib/api/firestore.ts: -------------------------------------------------------------------------------- 1 | import * as admin from "firebase-admin"; 2 | import { getFirestore } from "firebase-admin/firestore"; 3 | 4 | if (admin.apps.length === 0) { 5 | admin.initializeApp(); 6 | } 7 | 8 | export const db = getFirestore(); 9 | -------------------------------------------------------------------------------- /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/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: React.FC>; 3 | export default content; 4 | } 5 | 6 | declare module "badge-maker/lib/make-badge" { 7 | const makeBadge: (format: any) => string; 8 | export default makeBadge; 9 | } 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(docker build:*)", 5 | 6 | "Bash(bun install:*)", 7 | "Bash(bun run build:*)", 8 | "Bash(bun run lint)", 9 | "Bash(bun run e2e:*)", 10 | 11 | "WebFetch(domain:nextjs.org)", 12 | "WebFetch(domain:www.npmjs.com)" 13 | ], 14 | "deny": [] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.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: bun run build 17 | -------------------------------------------------------------------------------- /frontend/src/lib/renderBadge.ts: -------------------------------------------------------------------------------- 1 | import makeBadge from "badge-maker/lib/make-badge"; 2 | import { RenderBadgeOptions } from "@/lib/badge"; 3 | 4 | export const renderBadge = (options: RenderBadgeOptions): string => { 5 | return makeBadge({ 6 | logo: options.logoDataUrl, 7 | color: options.color, 8 | label: options.label, 9 | message: options.message, 10 | style: options.style, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/lib/api/api.ts: -------------------------------------------------------------------------------- 1 | export type ApiResult = 2 | | { 3 | data: T; 4 | error?: undefined; 5 | } 6 | | { 7 | data: null; 8 | error: ApiError; 9 | }; 10 | 11 | export const ApiError = { 12 | UserNotFound: "USER_NOT_FOUND", 13 | DataNotFound: "DATA_NOT_FOUND", 14 | RateLimit: "RATE_LIMIT", 15 | } as const; 16 | 17 | export type ApiError = (typeof ApiError)[keyof typeof ApiError]; 18 | -------------------------------------------------------------------------------- /.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: bun run e2e 16 | env: 17 | STEPCI_DISABLE_ANALYTICS: "1" 18 | -------------------------------------------------------------------------------- /frontend/src/lib/badge.ts: -------------------------------------------------------------------------------- 1 | export const BadgeStyle = { 2 | plastic: "plastic", 3 | flat: "flat", 4 | flatSquare: "flat-square", 5 | social: "social", 6 | forTheBadge: "for-the-badge", 7 | } as const; 8 | 9 | export type BadgeStyle = (typeof BadgeStyle)[keyof typeof BadgeStyle]; 10 | 11 | export type RenderBadgeOptions = { 12 | logoDataUrl: string; 13 | color: string; 14 | label: string; 15 | message: string; 16 | style: BadgeStyle; 17 | }; 18 | -------------------------------------------------------------------------------- /terraform/app/sa.tf: -------------------------------------------------------------------------------- 1 | resource "google_service_account" "main" { 2 | account_id = "${local.prefix}-frontend" 3 | } 4 | 5 | locals { 6 | frontend_roles = [ 7 | "roles/datastore.user", 8 | ] 9 | } 10 | 11 | resource "google_project_iam_member" "main" { 12 | for_each = toset(local.frontend_roles) 13 | 14 | project = data.google_project.main.id 15 | role = each.value 16 | member = "serviceAccount:${google_service_account.main.email}" 17 | } 18 | -------------------------------------------------------------------------------- /terraform/github-actions/sa.tf: -------------------------------------------------------------------------------- 1 | resource "google_service_account" "main" { 2 | account_id = "gh-actions" 3 | } 4 | 5 | locals { 6 | sa_roles = [ 7 | "roles/iam.serviceAccountUser", 8 | "roles/artifactregistry.repoAdmin", 9 | "roles/run.developer", 10 | ] 11 | } 12 | 13 | resource "google_project_iam_member" "service_account" { 14 | for_each = toset(local.sa_roles) 15 | 16 | project = data.google_project.main.id 17 | role = each.value 18 | member = "serviceAccount:${google_service_account.main.email}" 19 | } 20 | -------------------------------------------------------------------------------- /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/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/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/public/logos/zenn.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/components/util/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink, { LinkProps as NextLinkProps } from "next/link"; 2 | import React from "react"; 3 | 4 | type ExternalAnchorProps = React.HTMLProps; 5 | 6 | export type LinkProps = 7 | | ({ 8 | external: true; 9 | } & ExternalAnchorProps) 10 | | ({ 11 | external?: false; 12 | } & NextLinkProps); 13 | 14 | const Link: React.FC = React.memo((props) => { 15 | if (props.external) { 16 | return ; 17 | } 18 | 19 | return ; 20 | }); 21 | 22 | Link.displayName = "Link"; 23 | 24 | export default Link; 25 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "es5", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /frontend/public/logos/bluesky.svg: -------------------------------------------------------------------------------- 1 | 2 | Bluesky 3 | 6 | 7 | -------------------------------------------------------------------------------- /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/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/lib/api/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { logger } from "@/lib/logger"; 3 | 4 | export const axiosInstance = axios.create(); 5 | 6 | axiosInstance.interceptors.request.use((config) => { 7 | logger.info(`sending request to ${config.url}.`, { 8 | requestMethod: config.method, 9 | requestUrl: config.url, 10 | requestHeaders: config.headers, 11 | }); 12 | 13 | return config; 14 | }); 15 | 16 | axiosInstance.interceptors.response.use((resp) => { 17 | logger.info(`got response from ${resp.config.url}.`, { 18 | requestMethod: resp.config.method, 19 | requestUrl: resp.config.url, 20 | requestHeaders: resp.config.headers, 21 | responseStatus: resp.status, 22 | responseHeaders: resp.headers, 23 | }); 24 | 25 | return resp; 26 | }); 27 | -------------------------------------------------------------------------------- /.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@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4 8 | 9 | - name: Get bun cache directory 10 | id: cache-dir 11 | shell: bash 12 | run: echo "dir=$(bun pm 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 }}-bun-${{ hashFiles('**/bun.lock') }} 19 | restore-keys: | 20 | ${{ runner.os }}-bun- 21 | 22 | - run: bun install --frozen-lockfile 23 | working-directory: frontend 24 | shell: bash 25 | -------------------------------------------------------------------------------- /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/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/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1.2.16-alpine@sha256:14beb61b5209e743341a80ff2b05e2eac0ccb794b03902e67735f85f731c2b52 AS deps 2 | WORKDIR /app 3 | COPY package.json bun.lock ./ 4 | RUN bun install --frozen-lockfile 5 | 6 | FROM oven/bun:1.2.16-alpine@sha256:14beb61b5209e743341a80ff2b05e2eac0ccb794b03902e67735f85f731c2b52 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 bun run build 14 | 15 | FROM oven/bun:1.2.16-alpine@sha256:14beb61b5209e743341a80ff2b05e2eac0ccb794b03902e67735f85f731c2b52 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 ["bun", "run", "server.js"] 23 | -------------------------------------------------------------------------------- /frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useEffect, useState } from "react"; 3 | import Layout from "@/components/Layout"; 4 | import type { AppProps } from "next/app"; 5 | import "@/styles/global.css"; 6 | 7 | const MyApp = ({ Component, pageProps }: AppProps) => { 8 | const [mounted, setMounted] = useState(false); 9 | 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | setMounted(true); 14 | }, []); 15 | 16 | useEffect(() => { 17 | if (!mounted) return; 18 | if (!process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID) return; 19 | window.gtag("config", process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID, { 20 | page_path: router.pathname, 21 | }); 22 | }, [mounted, router.pathname]); 23 | 24 | return ( 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default MyApp; 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /terraform/github-actions/workload_identity.tf: -------------------------------------------------------------------------------- 1 | resource "google_iam_workload_identity_pool" "main" { 2 | workload_identity_pool_id = "gh-actions-pool" 3 | } 4 | 5 | resource "google_iam_workload_identity_pool_provider" "main" { 6 | workload_identity_pool_provider_id = "gh-actions-pool-provider" 7 | workload_identity_pool_id = google_iam_workload_identity_pool.main.workload_identity_pool_id 8 | 9 | oidc { 10 | issuer_uri = "https://token.actions.githubusercontent.com" 11 | } 12 | 13 | attribute_mapping = { 14 | "google.subject" = "assertion.sub" 15 | "attribute.repository" = "assertion.repository" 16 | "attribute.actor" = "assertion.actor" 17 | } 18 | } 19 | 20 | resource "google_service_account_iam_member" "workload_identity" { 21 | service_account_id = google_service_account.main.id 22 | role = "roles/iam.workloadIdentityUser" 23 | member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.main.name}/attribute.repository/koki-develop/badge-generator" 24 | } 25 | -------------------------------------------------------------------------------- /terraform/app/cloud_run.tf: -------------------------------------------------------------------------------- 1 | resource "google_cloud_run_service" "main" { 2 | name = "frontend" 3 | location = local.region 4 | template { 5 | spec { 6 | service_account_name = google_service_account.main.name 7 | containers { 8 | image = "${local.region}-docker.pkg.dev/${data.google_project.main.project_id}/${google_artifact_registry_repository.main.name}/frontend:latest" 9 | } 10 | } 11 | } 12 | 13 | lifecycle { 14 | ignore_changes = [ 15 | template, 16 | ] 17 | } 18 | } 19 | 20 | data "google_iam_role" "run_invoker" { 21 | name = "roles/run.invoker" 22 | } 23 | 24 | data "google_iam_policy" "cloud_run_main_noauth" { 25 | binding { 26 | role = data.google_iam_role.run_invoker.name 27 | members = ["allUsers"] 28 | } 29 | } 30 | 31 | resource "google_cloud_run_service_iam_policy" "main_noauth" { 32 | location = google_cloud_run_service.main.location 33 | project = google_cloud_run_service.main.project 34 | service = google_cloud_run_service.main.name 35 | policy_data = data.google_iam_policy.cloud_run_main_noauth.policy_data 36 | } 37 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/src/tasks/buildLogos.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | const _readAsBase64 = (...paths: string[]): string => { 5 | return fs 6 | .readFileSync(path.resolve(process.cwd(), ...paths)) 7 | .toString("base64"); 8 | }; 9 | 10 | const _base64ToDataUrl = (base64: string, mime: string): string => 11 | `data:${mime};base64,${base64}`; 12 | 13 | const mime = { 14 | png: "image/png", 15 | svg: "image/svg+xml", 16 | } as const; 17 | 18 | const logos = { 19 | zenn: { filename: "zenn.svg", mime: mime.svg }, 20 | qiita: { filename: "qiita.png", mime: mime.png }, 21 | atcoderBlack: { filename: "atcoder_black.svg", mime: mime.svg }, 22 | atcoderWhite: { filename: "atcoder_white.svg", mime: mime.svg }, 23 | bluesky: { filename: "bluesky.svg", mime: mime.svg }, 24 | }; 25 | 26 | const dataUrls = Object.entries(logos).reduce>( 27 | (result, [name, logo]) => { 28 | const base64 = _readAsBase64("public/logos", logo.filename); 29 | result[name] = _base64ToDataUrl(base64, logo.mime); 30 | return result; 31 | }, 32 | {}, 33 | ); 34 | 35 | fs.writeFileSync("src/logos.json", JSON.stringify(dataUrls)); 36 | -------------------------------------------------------------------------------- /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/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import NextDocument, { Head, Html, Main, NextScript } from "next/document"; 2 | import React from "react"; 3 | 4 | export default class Document extends NextDocument { 5 | render(): JSX.Element { 6 | return ( 7 | 8 | 9 | {/* Google tag (gtag.js) */} 10 | {process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID && ( 11 | <> 12 |