├── .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 |
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 |
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 |
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 |
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 |
16 |
27 | >
28 | )}
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/lib/api/zennApi.ts:
--------------------------------------------------------------------------------
1 | import { ApiError, ApiResult } from "@/lib/api/api";
2 | import { axiosInstance } from "@/lib/api/axios";
3 | import { withCache } from "@/lib/api/cache";
4 | import { withRate } from "./rate";
5 |
6 | export type ZennUser = {
7 | articles_count: number;
8 | books_count: number;
9 | follower_count: number;
10 | scraps_count: number;
11 | total_liked_count: number;
12 | };
13 |
14 | export const getUser = async (username: string) => _getUserWithCache(username);
15 |
16 | const _getUserWithCache = async (
17 | username: string,
18 | ): Promise> => {
19 | const cacheKey = `zenn_${username}`;
20 | return withCache(
21 | cacheKey,
22 | withRate("zenn", () => _getUser(username)),
23 | );
24 | };
25 |
26 | const _getUser = async (username: string): Promise> => {
27 | const url = new URL(
28 | `https://zenn.dev/api/users/${encodeURIComponent(username)}`,
29 | );
30 | const resp = await axiosInstance.get<{ user: ZennUser }>(url.href, {
31 | validateStatus: (status) => [200, 404].includes(status),
32 | });
33 | if (resp.status === 404) {
34 | return { data: null, error: ApiError.UserNotFound };
35 | }
36 |
37 | return { data: resp.data.user };
38 | };
39 |
--------------------------------------------------------------------------------
/terraform/app/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/google" {
5 | version = "4.38.0"
6 | constraints = "4.38.0"
7 | hashes = [
8 | "h1:ksQSZsc+S6GpHcdYO5SgbmIcSibsG/3Z2qqgW1XxxgE=",
9 | "zh:019ee2c826fa9e503b116909d8ef95e190f7e54078a72b3057a3e1f65b2ae0c2",
10 | "zh:2895bdd0036032ca4667cddecec2372d9ba190be8ecf2527d705ef3fb3f5c2fb",
11 | "zh:6bf593e604619fb413b7869ebe72de0ff883860cfc85d58ae06eb2b7cf088a6d",
12 | "zh:72d2ca1f36062a250a6b499363e3eb4c4b983a415b7c31c5ee7dab4dbeeaf020",
13 | "zh:7971431d90ecfdf3c50027f38447628801b77d03738717d6b22fb123e27a3dfc",
14 | "zh:821be1a1f709e6ef264a98339565609f5cfeb25b32ad6af5bf4b562fde5677e8",
15 | "zh:8b3811426eefd3c47c4de2990d129c809bc838a08a18b3497312121f3a482e73",
16 | "zh:a5e3c3aad4e7873014e4773fd8c261f74abc5cf6ab417c0fce3da2ed93154451",
17 | "zh:bb026e3c79408625fe013337cf7d7608e20b2b1c7b02d38a10780e191c08e56c",
18 | "zh:defa59b317eea43360a8303440398ed02717d8f29880ffad407ca7ebb63938fd",
19 | "zh:f4883b304c54dd0480af5463b3581b01bc43d9f573cfd9179d7e4d8b6af27147",
20 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/terraform/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/terraform
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=terraform
3 |
4 | ### Terraform ###
5 | # Local .terraform directories
6 | **/.terraform/*
7 |
8 | # .tfstate files
9 | *.tfstate
10 | *.tfstate.*
11 |
12 | # Crash log files
13 | crash.log
14 | crash.*.log
15 |
16 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as
17 | # password, private keys, and other secrets. These should not be part of version
18 | # control as they are data points which are potentially sensitive and subject
19 | # to change depending on the environment.
20 | *.tfvars
21 | *.tfvars.json
22 |
23 | # Ignore override files as they are usually used to override resources locally and so
24 | # are not checked in
25 | override.tf
26 | override.tf.json
27 | *_override.tf
28 | *_override.tf.json
29 |
30 | # Include override files you do wish to add to version control using negated pattern
31 | # !example_override.tf
32 |
33 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
34 | # example: *tfplan*
35 |
36 | # Ignore CLI configuration files
37 | .terraformrc
38 | terraform.rc
39 |
40 | # End of https://www.toptal.com/developers/gitignore/api/terraform
41 |
--------------------------------------------------------------------------------
/terraform/github-actions/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/google" {
5 | version = "4.38.0"
6 | constraints = "4.38.0"
7 | hashes = [
8 | "h1:ksQSZsc+S6GpHcdYO5SgbmIcSibsG/3Z2qqgW1XxxgE=",
9 | "zh:019ee2c826fa9e503b116909d8ef95e190f7e54078a72b3057a3e1f65b2ae0c2",
10 | "zh:2895bdd0036032ca4667cddecec2372d9ba190be8ecf2527d705ef3fb3f5c2fb",
11 | "zh:6bf593e604619fb413b7869ebe72de0ff883860cfc85d58ae06eb2b7cf088a6d",
12 | "zh:72d2ca1f36062a250a6b499363e3eb4c4b983a415b7c31c5ee7dab4dbeeaf020",
13 | "zh:7971431d90ecfdf3c50027f38447628801b77d03738717d6b22fb123e27a3dfc",
14 | "zh:821be1a1f709e6ef264a98339565609f5cfeb25b32ad6af5bf4b562fde5677e8",
15 | "zh:8b3811426eefd3c47c4de2990d129c809bc838a08a18b3497312121f3a482e73",
16 | "zh:a5e3c3aad4e7873014e4773fd8c261f74abc5cf6ab417c0fce3da2ed93154451",
17 | "zh:bb026e3c79408625fe013337cf7d7608e20b2b1c7b02d38a10780e191c08e56c",
18 | "zh:defa59b317eea43360a8303440398ed02717d8f29880ffad407ca7ebb63938fd",
19 | "zh:f4883b304c54dd0480af5463b3581b01bc43d9f573cfd9179d7e4d8b6af27147",
20 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/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/lib/api/bluesky.ts:
--------------------------------------------------------------------------------
1 | import { BskyAgent } from "@atproto/api";
2 | import {
3 | ProfileView,
4 | ProfileViewDetailed,
5 | } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
6 | import { logger } from "@/lib/logger";
7 | import { ApiError, ApiResult } from "./api";
8 | import { withCache } from "./cache";
9 | import { withRate } from "./rate";
10 |
11 | const agent = new BskyAgent({ service: "https://bsky.social" });
12 |
13 | export const getProfile = async (
14 | handle: string,
15 | ): Promise> => _getProfileWithCache(handle);
16 |
17 | const _getProfileWithCache = async (
18 | handle: string,
19 | ): Promise> => {
20 | const cacheKey = `bluesky_${handle}`;
21 | return withCache(
22 | cacheKey,
23 | withRate("bluesky", () => _getProfile(handle)),
24 | );
25 | };
26 |
27 | const _getProfile = async (
28 | handle: string,
29 | ): Promise> => {
30 | if (!agent.session) {
31 | logger.info("logging in to bluesky.");
32 | await agent.login({
33 | identifier: process.env.BLUESKY_IDENTIFIER!,
34 | password: process.env.BLUESKY_PASSWORD!,
35 | });
36 | }
37 |
38 | return await agent
39 | .getProfile({ actor: handle })
40 | .then(({ data }) => {
41 | return { data };
42 | })
43 | .catch(() => {
44 | return {
45 | data: null,
46 | error: ApiError.UserNotFound,
47 | };
48 | });
49 | };
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/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/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/lib/badgeUrl.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { Query } from "@/api/api";
3 | import { AtCoderBadgeType } from "@/api/atcoder";
4 | import { BlueskyBadgeType } from "@/api/bluesky";
5 | import { QiitaBadgeType } from "@/api/qiita";
6 | import { ZennBadgeType } from "@/api/zenn";
7 |
8 | const baseUrl =
9 | process.env.NODE_ENV === "production"
10 | ? "https://badgen.org"
11 | : "http://localhost:3000";
12 |
13 | const _buildBadgeUrl = (paths: string[], query: Query) => {
14 | const url = new URL(baseUrl);
15 | url.pathname = path.join(url.pathname, ...paths);
16 |
17 | if (query.style) url.searchParams.set("style", query.style);
18 | if (query.label?.trim()) url.searchParams.set("label", query.label);
19 |
20 | return url.href;
21 | };
22 |
23 | export const buildZennBadgeUrl = (type: ZennBadgeType) => (query: Query) => {
24 | return _buildBadgeUrl(["img/zenn", query.username, type], query);
25 | };
26 |
27 | export const buildQiitaBadgeUrl = (type: QiitaBadgeType) => (query: Query) => {
28 | return _buildBadgeUrl(["img/qiita", query.username, type], query);
29 | };
30 |
31 | export const buildAtCoderBadgeUrl =
32 | (type: AtCoderBadgeType) => (query: Query) => {
33 | const suffix = {
34 | algorithm_rating: "rating/algorithm",
35 | heuristic_rating: "rating/heuristic",
36 | }[type];
37 |
38 | return _buildBadgeUrl(["img/atcoder", query.username, suffix], query);
39 | };
40 |
41 | export const buildBlueskyBadgeUrl =
42 | (type: BlueskyBadgeType) => (query: Query) => {
43 | return _buildBadgeUrl(["img/bluesky", query.username, type], query);
44 | };
45 |
--------------------------------------------------------------------------------
/frontend/src/lib/api/atcoderApi.ts:
--------------------------------------------------------------------------------
1 | import { ApiResult, ApiError } from "@/lib/api/api";
2 | import { axiosInstance } from "@/lib/api/axios";
3 | import { withCache } from "@/lib/api/cache";
4 | import { withRate } from "@/lib/api/rate";
5 |
6 | export const getAlgorithmRating = async (
7 | username: string,
8 | ): Promise> => {
9 | return _getRatingWithCache(username, "algorithm");
10 | };
11 |
12 | export const getHeuristicRating = async (
13 | username: string,
14 | ): Promise> => {
15 | return _getRatingWithCache(username, "heuristic");
16 | };
17 |
18 | const _getRatingWithCache = async (
19 | username: string,
20 | type: "algorithm" | "heuristic",
21 | ): Promise> => {
22 | const cacheKey = `atcoder_${type}_rating_${username}`;
23 | return withCache(
24 | cacheKey,
25 | withRate("atcoder", () => _getRating(username, type)),
26 | );
27 | };
28 |
29 | const _getRating = async (
30 | username: string,
31 | type: "algorithm" | "heuristic",
32 | ): Promise> => {
33 | const url = new URL(
34 | `https://atcoder.jp/users/${encodeURIComponent(username)}/history/json`,
35 | );
36 | const contestType: string = { algorithm: "algo", heuristic: "heuristic" }[
37 | type
38 | ];
39 | url.searchParams.set("contestType", contestType);
40 |
41 | const resp = await axiosInstance.get<{ NewRating: number }[]>(url.href);
42 | const { data } = resp;
43 | if (data.length === 0) {
44 | return { data: null, error: ApiError.DataNotFound };
45 | }
46 |
47 | // get last element
48 | return { data: data[data.length - 1].NewRating };
49 | };
50 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "license": "MIT",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build:logos": "bun run ./src/tasks/buildLogos.ts",
8 | "prebuild": "bun run build:logos",
9 | "build": "next build",
10 | "start": "next start",
11 | "lint": "next lint",
12 | "fmt": "prettier --write .",
13 | "e2e": "stepci run e2e/workflow.yml"
14 | },
15 | "dependencies": {
16 | "@atproto/api": "0.9.7",
17 | "@headlessui/react": "1.7.15",
18 | "axios": "0.30.0",
19 | "badge-maker": "3.3.1",
20 | "cheerio": "1.0.0-rc.12",
21 | "classnames": "2.3.2",
22 | "copy-to-clipboard": "3.3.2",
23 | "date-fns": "2.29.3",
24 | "firebase-admin": "11.0.1",
25 | "next": "14.2.35",
26 | "react": "18.2.0",
27 | "react-dom": "18.2.0",
28 | "react-icons": "4.4.0",
29 | "react-scroll": "1.8.7",
30 | "winston": "3.8.2"
31 | },
32 | "devDependencies": {
33 | "@svgr/webpack": "6.3.1",
34 | "@tailwindcss/postcss": "4.1.10",
35 | "@types/gtag.js": "0.0.20",
36 | "@types/node": "18.19.130",
37 | "@types/react": "18.0.21",
38 | "@types/react-dom": "18.0.6",
39 | "@types/react-scroll": "1.8.4",
40 | "@typescript-eslint/eslint-plugin": "5.62.0",
41 | "eslint": "8.24.0",
42 | "eslint-config-next": "14.2.30",
43 | "eslint-config-prettier": "8.5.0",
44 | "eslint-plugin-unused-imports": "2.0.0",
45 | "postcss": "8.4.31",
46 | "prettier": "3.5.3",
47 | "prettier-plugin-tailwindcss": "0.6.12",
48 | "stepci": "2.5.6",
49 | "tailwindcss": "4.1.10",
50 | "typescript": "4.8.4"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/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/lib/api/cache.ts:
--------------------------------------------------------------------------------
1 | import crypto from "crypto";
2 | import { addHours } from "date-fns";
3 | import { Timestamp } from "firebase-admin/firestore";
4 | import { ApiError, ApiResult } from "@/lib/api/api";
5 | import { db } from "@/lib/api/firestore";
6 | import { logger } from "@/lib/logger";
7 |
8 | const version = "v2";
9 | const ttlHours = 3;
10 |
11 | const collectionKey = `caches_${version}`;
12 |
13 | type Cache = {
14 | data: ApiResult;
15 | expiration: Timestamp;
16 | };
17 |
18 | export const saveCache = async (key: string, data: T): Promise => {
19 | await db
20 | .collection(collectionKey)
21 | .doc(_md5(key))
22 | .set({
23 | data,
24 | expiration: addHours(new Date(), ttlHours),
25 | });
26 | };
27 |
28 | export const loadCache = async (key: string): Promise | null> => {
29 | const docId = _md5(key);
30 | const doc = await db.collection(collectionKey).doc(docId).get();
31 | if (!doc.exists) return null;
32 |
33 | const cache = doc.data() as Cache;
34 | if (_isExpired(cache)) return null;
35 |
36 | return cache;
37 | };
38 |
39 | export const withCache = async (
40 | key: string,
41 | func: () => Promise>,
42 | ): Promise> => {
43 | const cache = await loadCache(key);
44 | if (cache != null) {
45 | logger.info(`cache found:${key}`, { key });
46 | return cache.data;
47 | }
48 | logger.info(`cache not found: ${key}`, { key });
49 |
50 | const value = await func();
51 | if (value.error !== ApiError.RateLimit) {
52 | await saveCache(key, value);
53 | }
54 | logger.info(`cache saved: ${key}`, { key });
55 |
56 | return value;
57 | };
58 |
59 | const _isExpired = (cache: Cache): boolean =>
60 | new Date() > cache.expiration.toDate();
61 |
62 | const _md5 = (str: string) =>
63 | crypto.createHash("md5").update(str).digest("hex");
64 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## プロジェクト概要
6 |
7 | Badge Generator - 様々なプラットフォーム(Zenn、Qiita、AtCoder、Bluesky)のステータスバッジを生成するWebサービス(badgen.org)
8 |
9 | ## 開発コマンド
10 |
11 | ```bash
12 | # 開発環境での作業はfrontendディレクトリ内で行う
13 | cd frontend
14 |
15 | # 開発サーバー起動(http://localhost:3000)
16 | npm run dev
17 |
18 | # プロダクションビルド(ロゴファイルのビルドも自動実行)
19 | npm run build
20 |
21 | # リント実行
22 | npm run lint
23 |
24 | # E2Eテスト実行(Stepciを使用、本番環境に対して実行)
25 | npm run e2e
26 |
27 | # ロゴファイルのビルド(通常はbuild時に自動実行)
28 | npm run build:logos
29 | ```
30 |
31 | ## アーキテクチャ
32 |
33 | ### APIエンドポイント構造
34 | - `/api/[service]/[username]/[metric]` - 各サービスのメトリクスバッジを生成
35 | - サービス: atcoder, bluesky, qiita, zenn
36 | - 全てのAPIハンドラーは `renderSvg()` でラップされ、共通のエラーハンドリングとレスポンス処理を行う
37 |
38 | ### 重要なシステム設計
39 |
40 | 1. **キャッシュシステム** (`src/lib/api/cache.ts`)
41 | - Firebase Firestoreを使用、TTL 3時間
42 | - `withCache()` でAPIコールをラップすることで自動キャッシュ
43 |
44 | 2. **レート制限** (`src/lib/api/rate.ts`)
45 | - サービスごとに1時間1000リクエスト制限
46 | - Firestoreのシャーディングカウンター(10シャード)で実装
47 | - `withRate()` でAPIコールをラップすることで自動制限
48 |
49 | 3. **バッジ生成フロー**
50 | ```
51 | APIエンドポイント → renderSvg() → データ取得(withCache/withRate) → badge-makerでSVG生成
52 | ```
53 |
54 | ### エラーハンドリング
55 | - `ApiError.UserNotFound` - ユーザーが見つからない(404)
56 | - `ApiError.DataNotFound` - データが見つからない(404)
57 | - `ApiError.RateLimit` - レート制限超過(503)
58 |
59 | ### スタイルサポート
60 | plastic, flat, flat-square, social, for-the-badge
61 |
62 | ## 技術スタック
63 | - Next.js 14.2.30 (React 18.2.0)
64 | - TypeScript 4.8.4
65 | - Node.js 18.20.6
66 | - Firebase Admin SDK(Firestore)
67 | - Tailwind CSS
68 | - badge-maker(SVG生成)
69 |
70 | ## テスト環境
71 | - E2EテストはStepciを使用し、`e2e/workflow.yml`で定義
72 | - 本番環境(badgen.org)に対してAPIエンドポイントをテスト
73 | - 全サービス(Zenn、Qiita、AtCoder、Bluesky)のバッジ生成を検証
74 |
75 | ## デプロイメント
76 | - Terraformでインフラ管理(App Engine, Cloud Run)
77 | - GitHub ActionsでCI/CD
78 | - release-pleaseで自動リリース管理
--------------------------------------------------------------------------------
/.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 | release-please:
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: write
16 | pull-requests: write
17 | issues: write
18 | outputs:
19 | release-created: ${{ steps.release-please.outputs.release_created }}
20 | steps:
21 | - id: release-please
22 | uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
23 | with:
24 | release-type: simple
25 |
26 | build:
27 | needs:
28 | - release-please
29 | if: ${{ needs.release-please.outputs.release-created == 'true' }}
30 | permissions:
31 | contents: 'read'
32 | id-token: 'write'
33 | runs-on: ubuntu-latest
34 | steps:
35 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
36 | - run: ./bin/ctrl.sh build
37 | env:
38 | GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }}
39 | - uses: google-github-actions/auth@09cecabe1f169596b81c2ef22b40faff87acc460 # v0.9.0
40 | with:
41 | workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
42 | service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
43 | - run: ./bin/ctrl.sh push
44 |
45 | deploy:
46 | needs:
47 | - build
48 | permissions:
49 | contents: 'read'
50 | id-token: 'write'
51 | runs-on: ubuntu-latest
52 | steps:
53 | - name: checkout
54 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
55 | - uses: google-github-actions/auth@09cecabe1f169596b81c2ef22b40faff87acc460 # v0.9.0
56 | with:
57 | workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
58 | service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
59 | - run: ./bin/ctrl.sh deploy
60 | env:
61 | QIITA_ACCESS_TOKEN: ${{ secrets.QIITA_ACCESS_TOKEN }}
62 | BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }}
63 | BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }}
64 | - run: ./bin/ctrl.sh clean_images
65 |
66 | e2e:
67 | needs:
68 | - deploy
69 | uses: ./.github/workflows/_e2e.yml
70 |
--------------------------------------------------------------------------------
/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/lib/api/rate.ts:
--------------------------------------------------------------------------------
1 | import { addHours } from "date-fns";
2 | import { FieldValue, Timestamp } from "firebase-admin/firestore";
3 | import { ApiError, ApiResult } from "@/lib/api/api";
4 | import { db } from "@/lib/api/firestore";
5 | import { logger } from "@/lib/logger";
6 |
7 | const version = "v1";
8 | const resetHours = 1;
9 | const limit = 1000;
10 |
11 | const collectionKey = `rates_${version}`;
12 | const shardsCount = 10;
13 |
14 | type Meta = {
15 | reset: Timestamp;
16 | };
17 |
18 | export const withRate =
19 | (key: string, func: () => Promise>) =>
20 | async (): Promise> => {
21 | const meta = await _getMeta(key);
22 | if (!meta || _canReset(meta)) {
23 | logger.info(`reset ${key} rate.`, { key });
24 | await _resetRate(key);
25 | } else {
26 | const current = await _getRate(key);
27 | logger.info(`current ${key} rate: ${current}`, { key, rate: current });
28 | if (current > limit) {
29 | return { data: null, error: ApiError.RateLimit };
30 | }
31 | }
32 |
33 | await _increment(key);
34 | const result = await func();
35 | return result;
36 | };
37 |
38 | const _getRate = async (key: string): Promise => {
39 | const ref = await db.collection(`${collectionKey}/${key}/shards`).get();
40 | let rate = 0;
41 | ref.forEach((doc) => {
42 | rate += doc.data().count;
43 | });
44 | return rate;
45 | };
46 |
47 | const _increment = async (key: string): Promise => {
48 | await db
49 | .collection(`${collectionKey}/${key}/shards`)
50 | .doc(_randomShardId())
51 | .update("count", FieldValue.increment(1));
52 | };
53 |
54 | const _getMeta = async (key: string): Promise => {
55 | const doc = await db.collection(collectionKey).doc(key).get();
56 | if (!doc.exists) {
57 | return null;
58 | }
59 | return doc.data() as Meta;
60 | };
61 |
62 | const _resetRate = async (key: string): Promise => {
63 | const ref = db.collection(collectionKey).doc(key);
64 | await ref.set({
65 | reset: addHours(new Date(), resetHours),
66 | });
67 |
68 | for (let i = 0; i < shardsCount; i++) {
69 | await db
70 | .collection(`${collectionKey}/${key}/shards`)
71 | .doc(i.toString())
72 | .set({ count: 0 });
73 | }
74 | };
75 |
76 | const _canReset = (meta: Meta): boolean => new Date() > meta.reset.toDate();
77 |
78 | const _randomShardId = (): string => {
79 | return Math.floor(Math.random() * shardsCount).toString();
80 | };
81 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [0.0.6](https://github.com/koki-develop/badge-generator/compare/v0.0.5...v0.0.6) (2025-12-15)
4 |
5 |
6 | ### Bug Fixes
7 |
8 | * **deps:** update dependency next to v14.2.35 [security] ([#61](https://github.com/koki-develop/badge-generator/issues/61)) ([64c0c68](https://github.com/koki-develop/badge-generator/commit/64c0c685b1ee0a0fd1fbd97dd2b7a23491dbb906))
9 |
10 | ## [0.0.5](https://github.com/koki-develop/badge-generator/compare/v0.0.4...v0.0.5) (2025-12-04)
11 |
12 |
13 | ### Bug Fixes
14 |
15 | * **deps:** update dependency next to v14.2.32 [security] ([#54](https://github.com/koki-develop/badge-generator/issues/54)) ([2ef704a](https://github.com/koki-develop/badge-generator/commit/2ef704a4b905c1bcbe7b8e16c9d01fbe984dd40e))
16 |
17 | ## [0.0.4](https://github.com/koki-develop/badge-generator/compare/v0.0.3...v0.0.4) (2025-06-14)
18 |
19 |
20 | ### Bug Fixes
21 |
22 | * Update Tailwind CSS v3 -> v4 ([#49](https://github.com/koki-develop/badge-generator/issues/49)) ([ad277d0](https://github.com/koki-develop/badge-generator/commit/ad277d01b473396768e37a5957e52ab282052195))
23 |
24 | ## [0.0.3](https://github.com/koki-develop/badge-generator/compare/v0.0.2...v0.0.3) (2025-06-14)
25 |
26 |
27 | ### Bug Fixes
28 |
29 | * migrate from npm to bun ([#41](https://github.com/koki-develop/badge-generator/issues/41)) ([94cf810](https://github.com/koki-develop/badge-generator/commit/94cf810134645a8767a5765c130a78feac2fac6d))
30 |
31 | ## [0.0.2](https://github.com/koki-develop/badge-generator/compare/v0.0.1...v0.0.2) (2025-06-14)
32 |
33 |
34 | ### Bug Fixes
35 |
36 | * **deps:** update dependency axios to v0.30.0 [security] ([#23](https://github.com/koki-develop/badge-generator/issues/23)) ([06663f0](https://github.com/koki-develop/badge-generator/commit/06663f00941ebca99e14f15160cc1ec79c46f761))
37 | * Next.js v12からv13へのアップグレード ([#43](https://github.com/koki-develop/badge-generator/issues/43)) ([c2bd2e1](https://github.com/koki-develop/badge-generator/commit/c2bd2e1d0174b0dbc1260bb478d2fd5b5f4dd9f6))
38 | * Next.js v13からv14へのアップグレード ([#45](https://github.com/koki-develop/badge-generator/issues/45)) ([0ef841b](https://github.com/koki-develop/badge-generator/commit/0ef841bf9b26c28ad9ae07f59083285523b0f078))
39 |
40 | ## 0.0.1 (2025-06-13)
41 |
42 |
43 | ### Features
44 |
45 | * Release ([89117c9](https://github.com/koki-develop/badge-generator/commit/89117c982a73c4668c1def3262945ec6804e574d))
46 |
47 |
48 | ### Bug Fixes
49 |
50 | * Update node 16 to 18 ([e6448df](https://github.com/koki-develop/badge-generator/commit/e6448dfceaae1393fb6d23cd2fbc695877724cbb))
51 |
--------------------------------------------------------------------------------
/frontend/src/lib/api/qiitaApi.ts:
--------------------------------------------------------------------------------
1 | import { load } from "cheerio";
2 | import { ApiError, ApiResult } from "@/lib/api/api";
3 | import { axiosInstance } from "@/lib/api/axios";
4 | import { withCache } from "@/lib/api/cache";
5 | import { withRate } from "@/lib/api/rate";
6 |
7 | export type QiitaUser = {
8 | followers_count: number;
9 | items_count: number;
10 | };
11 |
12 | export const getContributions = async (
13 | username: string,
14 | ): Promise> => _getContributionsWithCache(username);
15 |
16 | export const getUser = async (
17 | username: string,
18 | ): Promise> => _getUserWithCache(username);
19 |
20 | const _getUserWithCache = async (
21 | username: string,
22 | ): Promise> => {
23 | const cacheKey = `qiita_${username}`;
24 | return withCache(
25 | cacheKey,
26 | withRate("qiita", () => _getUser(username)),
27 | );
28 | };
29 |
30 | const _getUser = async (username: string): Promise> => {
31 | const url = new URL(
32 | `https://qiita.com/api/v2/users/${encodeURIComponent(username)}`,
33 | );
34 | const resp = await axiosInstance.get(url.href, {
35 | headers: { authorization: `Bearer ${process.env.QIITA_ACCESS_TOKEN}` },
36 | validateStatus: (status) => [200, 404].includes(status),
37 | });
38 | if (resp.status === 404) {
39 | return { data: null, error: ApiError.UserNotFound };
40 | }
41 |
42 | return { data: resp.data };
43 | };
44 |
45 | const _getContributionsWithCache = async (
46 | username: string,
47 | ): Promise> => {
48 | const cacheKey = `qiita_contributions_${username}`;
49 | return withCache(
50 | cacheKey,
51 | withRate("qiita", () => _getContributions(username)),
52 | );
53 | };
54 |
55 | const _getContributions = async (
56 | username: string,
57 | ): Promise> => {
58 | const url = new URL(
59 | `https://qiita.com/${encodeURIComponent(username)}/contributions`,
60 | );
61 | const resp = await axiosInstance.get(url.href, {
62 | validateStatus: (status) => [200, 404].includes(status),
63 | });
64 | if (resp.status === 404) {
65 | return { data: null, error: ApiError.UserNotFound };
66 | }
67 |
68 | const $ = load(resp.data);
69 | const text = $('a:contains("Contributions")').text();
70 | if (!text.endsWith("Contributions")) {
71 | return { data: null, error: ApiError.DataNotFound };
72 | }
73 |
74 | const contributions = Number(text.replaceAll("Contributions", ""));
75 | if (Number.isNaN(contributions)) {
76 | return { data: null, error: ApiError.DataNotFound };
77 | }
78 |
79 | return { data: contributions };
80 | };
81 |
--------------------------------------------------------------------------------
/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/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.link})`,
32 | },
33 | {
34 | label: "HTML",
35 | value: `
`,
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 |
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/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/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 |
113 | )}
114 |
115 |
116 | );
117 | });
118 |
119 | Input.displayName = "Input";
120 |
121 | export default Input;
122 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/koki-develop/badge-generator/actions/workflows/ci.yml)
2 | [](https://twitter.com/koki_develop)
3 |
4 | # Badge Generator
5 |
6 | [badgen.org](https://badgen.org) - シンプルなバッジ生成サービス。
7 |
8 | ## Badges
9 |
10 | ### Zenn
11 |
12 | [](https://zenn.dev/kou_pg_0131)
13 | [](https://zenn.dev/kou_pg_0131)
14 | [](https://zenn.dev/kou_pg_0131)
15 | [](https://zenn.dev/kou_pg_0131?tab=books)
16 | [](https://zenn.dev/kou_pg_0131?tab=scraps)
17 |
18 | [](https://zenn.dev/kou_pg_0131)
19 | [](https://zenn.dev/kou_pg_0131)
20 | [](https://zenn.dev/kou_pg_0131)
21 | [](https://zenn.dev/kou_pg_0131?tab=books)
22 | [](https://zenn.dev/kou_pg_0131?tab=scraps)
23 |
24 | [](https://zenn.dev/kou_pg_0131)
25 | [](https://zenn.dev/kou_pg_0131)
26 | [](https://zenn.dev/kou_pg_0131)
27 | [](https://zenn.dev/kou_pg_0131?tab=books)
28 | [](https://zenn.dev/kou_pg_0131?tab=scraps)
29 |
30 | [](https://zenn.dev/kou_pg_0131)
31 | [](https://zenn.dev/kou_pg_0131)
32 | [](https://zenn.dev/kou_pg_0131)
33 | [](https://zenn.dev/kou_pg_0131?tab=books)
34 | [](https://zenn.dev/kou_pg_0131?tab=scraps)
35 |
36 | [](https://zenn.dev/kou_pg_0131)
37 | [](https://zenn.dev/kou_pg_0131)
38 | [](https://zenn.dev/kou_pg_0131)
39 | [](https://zenn.dev/kou_pg_0131?tab=books)
40 | [](https://zenn.dev/kou_pg_0131?tab=scraps)
41 |
42 | ### Qiita
43 |
44 | [](https://qiita.com/koki_develop)
45 | [](https://qiita.com/koki_develop)
46 | [](https://qiita.com/koki_develop)
47 |
48 | [](https://qiita.com/koki_develop)
49 | [](https://qiita.com/koki_develop)
50 | [](https://qiita.com/koki_develop)
51 |
52 | [](https://qiita.com/koki_develop)
53 | [](https://qiita.com/koki_develop)
54 | [](https://qiita.com/koki_develop)
55 |
56 | [](https://qiita.com/koki_develop)
57 | [](https://qiita.com/koki_develop)
58 | [](https://qiita.com/koki_develop)
59 |
60 | [](https://qiita.com/koki_develop)
61 | [](https://qiita.com/koki_develop)
62 | [](https://qiita.com/koki_develop)
63 |
64 | ### AtCoder
65 |
66 | > [!NOTE]
67 | > [chokudai](https://atcoder.jp/users/chokudai) さんのバッジを表示しています。僕のではありません。
68 |
69 | [](https://atcoder.jp/users/chokudai?contestType=algo)
70 | [](https://atcoder.jp/users/chokudai?contestType=heuristic)
71 |
72 | [](https://atcoder.jp/users/chokudai?contestType=algo)
73 | [](https://atcoder.jp/users/chokudai?contestType=heuristic)
74 |
75 | [](https://atcoder.jp/users/chokudai?contestType=algo)
76 | [](https://atcoder.jp/users/chokudai?contestType=heuristic)
77 |
78 | [](https://atcoder.jp/users/chokudai?contestType=algo)
79 | [](https://atcoder.jp/users/chokudai?contestType=heuristic)
80 |
81 | [](https://atcoder.jp/users/chokudai?contestType=algo)
82 | [](https://atcoder.jp/users/chokudai?contestType=heuristic)
83 |
84 | ### Bluesky
85 |
86 | [](https://bsky.app/profile/koki.me)
87 | [](https://bsky.app/profile/koki.me)
88 |
89 | [](https://bsky.app/profile/koki.me)
90 | [](https://bsky.app/profile/koki.me)
91 |
92 | [](https://bsky.app/profile/koki.me)
93 | [](https://bsky.app/profile/koki.me)
94 |
95 | [](https://bsky.app/profile/koki.me)
96 | [](https://bsky.app/profile/koki.me)
97 |
98 | [](https://bsky.app/profile/koki.me)
99 | [](https://bsky.app/profile/koki.me)
100 |
101 | ## LICENSE
102 |
103 | [MIT](./LICENSE)
104 |
--------------------------------------------------------------------------------
/frontend/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
88 |
--------------------------------------------------------------------------------
/frontend/public/logos/atcoder_black.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------