├── src
├── index.ts
├── rating.ts
├── index.css
├── OGCard.stories.tsx
├── logo.tsx
├── decorations.tsx
├── theme.ts
├── OGCard.tsx
└── main.tsx
├── public
└── noto-sans-v27-latin-regular.ttf
├── .storybook
├── preview.ts
└── main.ts
├── .changeset
├── config.json
└── README.md
├── vite.playground.config.ts
├── .gitignore
├── index.html
├── vite.config.ts
├── .github
└── workflows
│ ├── release.yml
│ ├── deploy-pages.yml
│ ├── pr-preview.yml
│ └── commit-preview.yml
├── LICENSE
├── eslint.config.mjs
├── tsconfig.json
├── CHANGELOG.md
├── package.json
├── README.md
└── scripts
└── generate.ts
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './OGCard'
2 | export * from './theme'
3 |
--------------------------------------------------------------------------------
/src/rating.ts:
--------------------------------------------------------------------------------
1 | export enum Rating {
2 | S = 'S',
3 | A = 'A',
4 | B = 'B',
5 | C = 'C',
6 | D = 'D',
7 | E = 'E',
8 | }
9 |
--------------------------------------------------------------------------------
/public/noto-sans-v27-latin-regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitroll-dev/gitroll-profile-card/HEAD/public/noto-sans-v27-latin-regular.ttf
--------------------------------------------------------------------------------
/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react'
2 |
3 |
4 | const preview: Preview = {
5 | parameters: {
6 | controls: {
7 | matchers: {
8 | color: /(background|color)$/i,
9 | date: /Date$/i,
10 | },
11 | },
12 | },
13 | }
14 |
15 | export default preview
16 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | }
6 |
7 | input, select {
8 | font-family: inherit;
9 | }
10 |
11 | label {
12 | font-weight: 500;
13 | color: #4a4a4a;
14 | }
15 |
16 | input:focus, select:focus {
17 | border-color: #0066ff !important;
18 | outline: none;
19 | box-shadow: 0 0 0 3px rgba(0,102,255,0.1);
20 | }
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
3 | "changelog": ["@changesets/changelog-github", { "repo": "gitroll-dev/gitroll-profile-card" }],
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/vite.playground.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 |
3 |
4 | export default defineConfig({
5 | build: {
6 | sourcemap: true, // Generates source maps for debugging.
7 | emptyOutDir: true, // Clears the output directory before building.
8 | outDir: 'playground', // Specifies the output directory for the build.
9 | },
10 | base: 'gitroll-profile-card/',
11 | })
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | *storybook.log
27 |
28 | playground/
29 | /preview.png
30 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | GitRoll Dev Card Playground
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/react-vite'
2 |
3 |
4 | const config: StorybookConfig = {
5 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
6 | addons: [
7 | '@storybook/addon-onboarding',
8 | '@storybook/addon-essentials',
9 | '@chromatic-com/storybook',
10 | '@storybook/addon-interactions',
11 | ],
12 | core: {
13 | builder: '@storybook/builder-vite',
14 | },
15 | framework: {
16 | name: '@storybook/react-vite',
17 | options: {},
18 | },
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import dts from 'vite-plugin-dts'
3 | import { peerDependencies } from './package.json'
4 |
5 |
6 | export default defineConfig({
7 | build: {
8 | lib: {
9 | entry: './src/index.ts', // Specifies the entry point for building the library.
10 | name: 'gitroll-profile-card', // Sets the name of the generated library.
11 | fileName: (format) => `index.${format}.js`, // Generates the output file name based on the format.
12 | formats: ['cjs', 'es'], // Specifies the output formats (CommonJS and ES modules).
13 | },
14 | rollupOptions: {
15 | external: [...Object.keys(peerDependencies)], // Defines external dependencies for Rollup bundling.
16 | },
17 | sourcemap: true, // Generates source maps for debugging.
18 | emptyOutDir: true, // Clears the output directory before building.
19 | },
20 | plugins: [dts()], // Uses the 'vite-plugin-dts' plugin for generating TypeScript declaration files (d.ts).
21 | })
22 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Package
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 |
7 | concurrency: ${{ github.workflow }}-${{ github.ref }}
8 |
9 | jobs:
10 | release:
11 | runs-on: ubuntu-latest
12 | timeout-minutes: 60
13 | environment:
14 | name: publish
15 | permissions:
16 | packages: write
17 | contents: write
18 | issues: write
19 | pull-requests: write
20 | steps:
21 | - uses: actions/checkout@v4
22 | - uses: pnpm/action-setup@v4
23 | with:
24 | run_install: true
25 | - run: pnpm build
26 | - uses: changesets/action@v1
27 | with:
28 | publish: pnpm changeset publish
29 | version: pnpm changeset version
30 | title: Release Packages
31 | commit: bump versions
32 | env:
33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2024 GitRoll
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import importPlugin from 'eslint-plugin-import'
2 | import js from '@eslint/js'
3 | import stylistic from '@stylistic/eslint-plugin'
4 | import tseslint from 'typescript-eslint'
5 | import { includeIgnoreFile } from '@eslint/compat'
6 | import path from 'node:path'
7 | import { fileURLToPath } from 'node:url'
8 |
9 |
10 | const __filename = fileURLToPath(import.meta.url)
11 | const __dirname = path.dirname(__filename)
12 | const gitignorePath = path.resolve(__dirname, '.gitignore')
13 |
14 |
15 | export default [
16 | includeIgnoreFile(gitignorePath),
17 | js.configs.recommended,
18 | importPlugin.flatConfigs.recommended,
19 | ...tseslint.configs.recommended,
20 | {
21 | files: ['**/*.{js,mjs,cjs,ts,tsx}'],
22 | languageOptions: {
23 | ecmaVersion: 'latest',
24 | sourceType: 'module',
25 | },
26 | plugins: {
27 | '@stylistic': stylistic
28 | },
29 | rules: {
30 | '@stylistic/indent': ['warn', 2, { SwitchCase: 1 }],
31 | '@stylistic/quotes': ['error', 'single'],
32 | '@stylistic/semi': ['error', 'never'],
33 | 'import/newline-after-import': ['warn', { count: 2 }],
34 | 'react/jsx-uses-react': 'off',
35 | 'react/react-in-jsx-scope': 'off'
36 | },
37 | settings: {
38 | 'import/resolver': {
39 | typescript: true,
40 | node: true,
41 | },
42 | }
43 | },
44 | ]
--------------------------------------------------------------------------------
/.github/workflows/deploy-pages.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | # Runs on pushes targeting the default branch
5 | push:
6 | branches: ["main"]
7 |
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
12 | permissions:
13 | contents: read
14 | pages: write
15 | id-token: write
16 |
17 | # Allow only one concurrent deployment
18 | concurrency:
19 | group: "pages"
20 | cancel-in-progress: true
21 |
22 | jobs:
23 | deploy:
24 | environment:
25 | name: github-pages
26 | url: ${{ steps.deployment.outputs.page_url }}
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 |
32 | - name: Setup pnpm
33 | uses: pnpm/action-setup@v4
34 | with:
35 | run_install: true
36 |
37 | - name: Install dependencies
38 | run: pnpm install
39 |
40 | - name: Build playground
41 | run: pnpm build:playground
42 |
43 | - name: Setup Pages
44 | uses: actions/configure-pages@v5
45 |
46 | - name: Upload artifact
47 | uses: actions/upload-pages-artifact@v3
48 | with:
49 | path: "playground"
50 |
51 | - name: Deploy to GitHub Pages
52 | id: deployment
53 | uses: actions/deploy-pages@v4
54 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES5", // Specifies the JavaScript version to target when transpiling code.
4 | "useDefineForClassFields": true, // Enables the use of 'define' for class fields.
5 | "lib": [
6 | "ES2020",
7 | "DOM",
8 | "DOM.Iterable"
9 | ], // Specifies the libraries available for the code.
10 | "module": "ESNext", // Defines the module system to use for code generation.
11 | "skipLibCheck": true, // Skips type checking of declaration files.
12 | /* Bundler mode */
13 | "moduleResolution": "bundler", // Specifies how modules are resolved when bundling.
14 | "allowImportingTsExtensions": true, // Allows importing TypeScript files with extensions.
15 | "resolveJsonModule": true, // Enables importing JSON modules.
16 | "isolatedModules": true, // Ensures each file is treated as a separate module.
17 | "noEmit": true, // Prevents TypeScript from emitting output files.
18 | "jsx": "react-jsx", // Configures JSX support for React.
19 | /* Linting */
20 | "strict": true, // Enables strict type checking.
21 | "noUnusedLocals": true, // Flags unused local variables.
22 | "noUnusedParameters": true, // Flags unused function parameters.
23 | "noFallthroughCasesInSwitch": true, // Requires handling all cases in a switch statement.
24 | "declaration": true, // Generates declaration files for TypeScript.
25 | },
26 | "include": [
27 | "src"
28 | ], // Specifies the directory to include when searching for TypeScript files.
29 | "exclude": [
30 | "src/**/__docs__",
31 | "src/**/__test__"
32 | ]
33 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @gitroll/profile-card
2 |
3 | ## 0.3.0
4 |
5 | ### Minor Changes
6 |
7 | - [#27](https://github.com/gitroll-dev/gitroll-profile-card/pull/27) [`4a9e9d5`](https://github.com/gitroll-dev/gitroll-profile-card/commit/4a9e9d5d40a7e8fabed8012cd1252e040e28d9cd) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Add WatchdogGradient theme support
8 |
9 | ## 0.2.4
10 |
11 | ### Patch Changes
12 |
13 | - [#22](https://github.com/gitroll-dev/gitroll-profile-card/pull/22) [`c46830d`](https://github.com/gitroll-dev/gitroll-profile-card/commit/c46830d637934a1e108cb2e3994252d76d71f738) Thanks [@ggfevans](https://github.com/ggfevans)! - Added Dracula theme
14 |
15 | ## 0.2.3
16 |
17 | ### Patch Changes
18 |
19 | - [#19](https://github.com/gitroll-dev/gitroll-profile-card/pull/19) [`677b60f`](https://github.com/gitroll-dev/gitroll-profile-card/commit/677b60f3418bb5a8c6996c38a3ad67a60feaebc5) Thanks [@emmanyouwell](https://github.com/emmanyouwell)! - Added Dark Emerald theme
20 |
21 | ## 0.2.2
22 |
23 | ### Patch Changes
24 |
25 | - [#17](https://github.com/gitroll-dev/gitroll-profile-card/pull/17) [`7269c6f`](https://github.com/gitroll-dev/gitroll-profile-card/commit/7269c6f08d0b45b1035f8ddcae6fb8b9661adb84) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Add retro theme
26 |
27 | ## 0.2.1
28 |
29 | ### Patch Changes
30 |
31 | - Use full text of "maintainability" and shorten the bars.
32 |
33 | ## 0.2.0
34 |
35 | ### Minor Changes
36 |
37 | - [#12](https://github.com/gitroll-dev/gitroll-profile-card/pull/12) [`f7c4773`](https://github.com/gitroll-dev/gitroll-profile-card/commit/f7c4773a2630762e8b366646014fbad83c3614df) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - kawaiiCat theme
38 |
39 | ## 0.1.0
40 |
41 | ### Minor Changes
42 |
43 | - [#3](https://github.com/gitroll-dev/gitroll-profile-card/pull/3) [`b56b76e`](https://github.com/gitroll-dev/gitroll-profile-card/commit/b56b76eb6a7998a5f845723748be9a90a9852d08) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Add midnight theme
44 |
--------------------------------------------------------------------------------
/src/OGCard.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react'
2 | import { OGCard } from './OGCard'
3 | import { Rating } from './rating'
4 | import { preset } from './theme'
5 |
6 |
7 | const meta: Meta = {
8 | component: OGCard,
9 | parameters: {
10 | layout: 'centered',
11 | },
12 | argTypes: {
13 | overallRating: {
14 | control: { type: 'select' },
15 | options: Object.values(Rating),
16 | },
17 | },
18 | }
19 |
20 | export default meta
21 | type Story = StoryObj
22 |
23 | const baseProps = {
24 | user: 'JacobLinCool',
25 | avatar: 'https://github.com/jacoblincool.png',
26 | devType: 'Exemplary AI/ML Developer',
27 | overallScore: '9.05',
28 | overallScoreCDF: '99',
29 | overallRating: Rating.S,
30 | reliabilityScore: 4.37,
31 | securityScore: 5.0,
32 | maintainabilityScore: 4.86,
33 | contributor: true,
34 | regionalRank: [1, 'TW'] as [number, string],
35 | campusRank: [1, 'ntnu'] as [number, string],
36 | }
37 |
38 | export const Light: Story = {
39 | args: {
40 | ...baseProps,
41 | theme: preset.light,
42 | },
43 | }
44 |
45 | export const Dark: Story = {
46 | args: {
47 | ...baseProps,
48 | theme: preset.dark,
49 | },
50 | }
51 |
52 | export const Sepia: Story = {
53 | args: {
54 | ...baseProps,
55 | theme: preset.sepia,
56 | },
57 | }
58 |
59 | export const SolarizedLight: Story = {
60 | args: {
61 | ...baseProps,
62 | theme: preset.solarizedLight,
63 | },
64 | }
65 |
66 | export const SolarizedDark: Story = {
67 | args: {
68 | ...baseProps,
69 | theme: preset.solarizedDark,
70 | },
71 | }
72 |
73 | export const TokyoNight: Story = {
74 | args: {
75 | ...baseProps,
76 | theme: preset.tokyoNight,
77 | },
78 | }
79 |
80 | export const Nord: Story = {
81 | args: {
82 | ...baseProps,
83 | theme: preset.nord,
84 | },
85 | }
86 |
87 | export const Midnight: Story = {
88 | args:{
89 | ...baseProps,
90 | theme: preset.midnight
91 | }
92 | }
93 |
94 | export const Dracula: Story = {
95 | args: {
96 | ...baseProps,
97 | theme: preset.dracula,
98 | },
99 | }
100 |
101 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@gitroll/profile-card",
3 | "version": "0.3.0",
4 | "type": "module",
5 | "exports": {
6 | ".": {
7 | "types": "./dist/index.d.ts",
8 | "import": "./dist/index.es.js",
9 | "require": "./dist/index.cjs.js"
10 | }
11 | },
12 | "files": [
13 | "dist"
14 | ],
15 | "scripts": {
16 | "dev": "vite",
17 | "build": "tsc && vite build",
18 | "build:playground": "vite build -c vite.playground.config.ts",
19 | "lint": "eslint .",
20 | "preview": "vite preview",
21 | "storybook": "storybook dev -p 6006",
22 | "build-storybook": "storybook build",
23 | "changeset": "changeset",
24 | "generate": "tsx scripts/generate.ts"
25 | },
26 | "peerDependencies": {
27 | "react": "^18.3.1",
28 | "react-dom": "^18.3.1"
29 | },
30 | "devDependencies": {
31 | "@changesets/changelog-github": "^0.5.0",
32 | "@changesets/cli": "^2.27.10",
33 | "@chromatic-com/storybook": "^3.2.2",
34 | "@eslint/compat": "^1.2.2",
35 | "@eslint/js": "^9.14.0",
36 | "@storybook/addon-essentials": "^8.4.2",
37 | "@storybook/addon-interactions": "^8.4.2",
38 | "@storybook/addon-onboarding": "^8.4.2",
39 | "@storybook/blocks": "^8.4.2",
40 | "@storybook/react": "^8.4.2",
41 | "@storybook/react-vite": "^8.4.2",
42 | "@storybook/test": "^8.4.2",
43 | "@stylistic/eslint-plugin": "^2.10.1",
44 | "@types/node": "^22.8.6",
45 | "@types/react": "^18.3.12",
46 | "@types/react-dom": "^18.3.1",
47 | "@typescript-eslint/eslint-plugin": "^8.14.0",
48 | "@typescript-eslint/parser": "^8.14.0",
49 | "@vitejs/plugin-react": "^4.3.3",
50 | "eslint": "^9.14.0",
51 | "eslint-config-prettier": "^9.1.0",
52 | "eslint-import-resolver-typescript": "^3.6.3",
53 | "eslint-plugin-import": "^2.31.0",
54 | "eslint-plugin-prettier": "^5.2.1",
55 | "eslint-plugin-react": "^7.37.2",
56 | "eslint-plugin-react-hooks": "^5.0.0",
57 | "eslint-plugin-react-refresh": "^0.4.14",
58 | "eslint-plugin-storybook": "^0.11.0",
59 | "globals": "^15.11.0",
60 | "satori": "^0.11.3",
61 | "sharp": "^0.33.5",
62 | "storybook": "^8.4.2",
63 | "tsx": "^4.19.2",
64 | "typescript": "~5.6.3",
65 | "typescript-eslint": "^8.12.2",
66 | "vite": "^5.4.10",
67 | "vite-plugin-dts": "^4.3.0"
68 | },
69 | "packageManager": "pnpm@9.13.2"
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GitRoll Profile Card
2 |
3 | [Online Playground](https://gitroll-dev.github.io/gitroll-profile-card/)
4 |
5 | ## Available Themes
6 |
7 | We now have 13 preset themes to choose from! 🎉
8 |
9 | - **`light`** (default)
10 | - **`dark`**
11 | - **`sepia`**
12 | - **`solarizedLight`**
13 | - **`solarizedDark`**
14 | - **`tokyoNight`**
15 | - **`nord`**
16 | - **`midnight`**
17 | - **`kawaiiCat`**
18 | - **`retro`**
19 | - **`darkEmerald`**
20 | - **`dracula`**
21 | - **`WatchdogGradient`**
22 |
23 | To use a preset theme, simply add the `theme` query parameter to the image URL. For example:
24 |
25 | ```
26 | https://gitroll.io/api/badges/profiles/v1/uZxjMB3mkXpQQPskvTMcp0UeqPJA3?theme=nord
27 | ```
28 |
29 | Or try the new WatchdogGradient theme:
30 |
31 | ```
32 | https://gitroll.io/api/badges/profiles/v1/uZxjMB3mkXpQQPskvTMcp0UeqPJA3?theme=WatchdogGradient
33 | ```
34 |
35 | ## Contributing
36 |
37 | We welcome contributions to GitRoll Profile Card!
38 |
39 | ### Adding New Themes
40 |
41 | To keep the project simple and ensure the themes are useful to the community, any new theme must gather **at least 3 emoji reactions** from the community before the pull request (PR) is merged. This process ensures that the theme resonates with the users and maintains the quality of the themes offered.
42 |
43 | If you'd like to propose a new theme:
44 |
45 | 1. Fork the repository.
46 | 2. Develop your theme in a new branch.
47 | 3. Submit a PR for community review.
48 | 4. Gather at least 3 emoji reactions from the community to proceed with merging.
49 |
50 | ## Developing and Testing Themes
51 |
52 | To help you develop new themes and preview your work, please use the playground.
53 |
54 | ### Online Playground
55 |
56 | You can easily preview and test your themes using our [Online Playground](https://gitroll-dev.github.io/gitroll-profile-card/).
57 |
58 | The playground allows you to:
59 |
60 | - Preview your card in real-time with **Hot Module Replacement (HMR)**.
61 | - Try out different preset themes.
62 | - Test with custom properties, such as username, ranks, and scores.
63 |
64 | ### Running the Playground Locally
65 |
66 | To run the playground locally:
67 |
68 | 1. Clone the Repository
69 |
70 | ```sh
71 | git clone https://github.com/gitroll-dev/gitroll-profile-card.git
72 | ```
73 |
74 | 2. Install Dependencies
75 |
76 | ```sh
77 | pnpm install
78 | ```
79 |
80 | 3. Start the Development Server
81 |
82 | ```sh
83 | pnpm dev
84 | ```
85 |
86 | 4. Visit the Playground
87 |
88 | Open your browser and visit .
89 |
90 | This is a great way to experiment with different configurations and see how your card will look before submitting a PR.
91 |
92 | ## Feedback and Support
93 |
94 | If you encounter any issues or have suggestions, please open an issue on our [GitHub Issues page](https://github.com/gitroll-dev/gitroll-profile-card/issues). Your feedback is valuable to us and helps make GitRoll Profile Card better for everyone.
95 |
--------------------------------------------------------------------------------
/.github/workflows/pr-preview.yml:
--------------------------------------------------------------------------------
1 | name: PR Preview
2 |
3 | on:
4 | pull_request:
5 | types: [opened, synchronize, reopened]
6 |
7 | jobs:
8 | preview:
9 | runs-on: ubuntu-latest
10 | permissions:
11 | pull-requests: write
12 |
13 | steps:
14 | - name: Checkout PR Branch
15 | uses: actions/checkout@v4
16 |
17 | - name: Checkout main branch
18 | uses: actions/checkout@v4
19 | with:
20 | ref: main
21 | path: main-branch
22 |
23 | - uses: pnpm/action-setup@v4
24 | with:
25 | run_install: true
26 |
27 | - name: Find new theme
28 | id: get-theme
29 | run: |
30 | # Extract themes from both branches
31 | PR_THEMES=$(pnpm tsx -e "import { preset } from './src/theme'; console.log(Object.keys(preset).join(','))")
32 | MAIN_THEMES=$(pnpm tsx -e "import { preset } from './main-branch/src/theme'; console.log(Object.keys(preset).join(','))")
33 |
34 | # Convert to arrays
35 | IFS=',' read -ra PR_ARRAY <<< "$PR_THEMES"
36 | IFS=',' read -ra MAIN_ARRAY <<< "$MAIN_THEMES"
37 |
38 | # Find new themes
39 | NEW_THEMES=()
40 | for theme in "${PR_ARRAY[@]}"; do
41 | if [[ ! " ${MAIN_ARRAY[@]} " =~ " ${theme} " ]]; then
42 | NEW_THEMES+=("$theme")
43 | fi
44 | done
45 |
46 | if [ ${#NEW_THEMES[@]} -eq 0 ]; then
47 | THEME="light"
48 | echo "No new theme found, using default: $THEME"
49 | else
50 | THEME="${NEW_THEMES[0]}"
51 | echo "New theme found: $THEME"
52 | fi
53 |
54 | echo "theme=$THEME" >> $GITHUB_OUTPUT
55 |
56 | # Store all new themes for the comment
57 | if [ ${#NEW_THEMES[@]} -gt 0 ]; then
58 | echo "new_themes=${NEW_THEMES[*]}" >> $GITHUB_OUTPUT
59 | fi
60 |
61 | - name: Generate preview grid
62 | run: |
63 | pnpm generate --grid --theme ${{ steps.get-theme.outputs.theme }} -o preview.png || {
64 | echo "::error::Failed to generate preview cards"
65 | exit 1
66 | }
67 |
68 | - name: Upload preview artifact
69 | uses: actions/upload-artifact@v4
70 | with:
71 | name: preview-image
72 | path: preview.png
73 | retention-days: 1
74 |
75 | - name: Save PR info
76 | run: |
77 | echo "${{ github.event.pull_request.number }}" > pr_number.txt
78 | echo "${{ github.event.pull_request.head.sha }}" > commit_sha.txt
79 | echo "${{ steps.get-theme.outputs.new_themes }}" > new_themes.txt
80 | echo "${{ steps.get-theme.outputs.theme }}" > theme.txt
81 |
82 | - name: Upload PR info artifact
83 | uses: actions/upload-artifact@v4
84 | with:
85 | name: pr-info
86 | path: |
87 | pr_number.txt
88 | commit_sha.txt
89 | new_themes.txt
90 | theme.txt
91 | retention-days: 1
92 |
--------------------------------------------------------------------------------
/src/logo.tsx:
--------------------------------------------------------------------------------
1 | export const GitRollLogo = ({ fill = '#030303', width = 246, height = 60 }: { fill?: string; width?: number; height?: number }) => (
2 |
36 | )
37 |
--------------------------------------------------------------------------------
/.github/workflows/commit-preview.yml:
--------------------------------------------------------------------------------
1 | name: Commit Preview
2 |
3 | on:
4 | workflow_run:
5 | workflows: ["PR Preview"]
6 | types:
7 | - completed
8 |
9 | jobs:
10 | commit:
11 | runs-on: ubuntu-latest
12 | if: github.event.workflow_run.conclusion == 'success'
13 | permissions:
14 | contents: write
15 | pull-requests: write
16 |
17 | steps:
18 | - name: Download preview artifact
19 | uses: dawidd6/action-download-artifact@v7
20 | with:
21 | run_id: ${{ github.event.workflow_run.id }}
22 | name: preview-image
23 | path: .
24 |
25 | - name: Download PR info artifact
26 | uses: dawidd6/action-download-artifact@v7
27 | with:
28 | run_id: ${{ github.event.workflow_run.id }}
29 | name: pr-info
30 | path: .
31 |
32 | - name: Read PR info
33 | id: pr-info
34 | run: |
35 | echo "pr_number=$(cat pr_number.txt)" >> $GITHUB_OUTPUT
36 | echo "commit_sha=$(cat commit_sha.txt)" >> $GITHUB_OUTPUT
37 | echo "new_themes=$(cat new_themes.txt)" >> $GITHUB_OUTPUT
38 | echo "theme=$(cat theme.txt)" >> $GITHUB_OUTPUT
39 |
40 | - name: Setup preview branch
41 | run: |
42 | # Configure git
43 | git config --global user.name 'github-actions[bot]'
44 | git config --global user.email 'github-actions[bot]@users.noreply.github.com'
45 |
46 | # Create a temporary directory for git operations
47 | mkdir -p temp_git
48 | cd temp_git
49 |
50 | REPO_URL="https://github.com/${{ github.repository }}.git"
51 |
52 | # Clone only the preview branch, or create new if doesn't exist
53 | if git ls-remote --heads $REPO_URL previews | grep -q 'refs/heads/previews'; then
54 | git clone --branch previews --single-branch $REPO_URL .
55 | else
56 | git clone $REPO_URL .
57 | git checkout --orphan previews
58 | git rm -rf .
59 | git clean -fxd
60 | fi
61 |
62 | # Setup the preview branch
63 | mkdir -p previews
64 |
65 | # Copy the new preview with PR number and commit SHA from parent directory
66 | cp ../preview.png "previews/pr-${{ steps.pr-info.outputs.pr_number }}-${{ steps.pr-info.outputs.commit_sha }}.png"
67 |
68 | # Commit and push
69 | git add previews/
70 | git commit -m "Update preview for PR #${{ steps.pr-info.outputs.pr_number }} commit ${{ steps.pr-info.outputs.commit_sha }}" || echo "No changes to commit"
71 | git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git previews
72 |
73 | - name: Find Comment
74 | uses: peter-evans/find-comment@v3
75 | id: find-comment
76 | with:
77 | issue-number: ${{ steps.pr-info.outputs.pr_number }}
78 | comment-author: "github-actions[bot]"
79 | body-includes: "### GitRoll Preview Cards"
80 |
81 | - name: Create comment body
82 | id: create-comment
83 | run: |
84 | IMAGE_URL="https://raw.githubusercontent.com/${{ github.repository }}/previews/previews/pr-${{ steps.pr-info.outputs.pr_number }}-${{ steps.pr-info.outputs.commit_sha }}.png"
85 |
86 | echo "body<> $GITHUB_OUTPUT
87 | echo "### GitRoll Preview Cards" >> $GITHUB_OUTPUT
88 | echo "" >> $GITHUB_OUTPUT
89 | if [[ -n "${{ steps.pr-info.outputs.new_themes }}" ]]; then
90 | echo "New theme(s) detected: \`${{ steps.pr-info.outputs.new_themes }}\`" >> $GITHUB_OUTPUT
91 | else
92 | echo "No new theme detected, using: \`${{ steps.pr-info.outputs.theme }}\`" >> $GITHUB_OUTPUT
93 | fi
94 | echo "" >> $GITHUB_OUTPUT
95 | echo "" >> $GITHUB_OUTPUT
96 | echo "" >> $GITHUB_OUTPUT
97 | echo "These are preview cards showing possible ratings. Get your real score at [GitRoll.io](https://gitroll.io)" >> $GITHUB_OUTPUT
98 | echo "EOF" >> $GITHUB_OUTPUT
99 |
100 | - name: Create or update comment
101 | uses: peter-evans/create-or-update-comment@v4
102 | with:
103 | comment-id: ${{ steps.find-comment.outputs.comment-id }}
104 | issue-number: ${{ steps.pr-info.outputs.pr_number }}
105 | body: ${{ steps.create-comment.outputs.body }}
106 | edit-mode: replace
107 |
--------------------------------------------------------------------------------
/src/decorations.tsx:
--------------------------------------------------------------------------------
1 | interface KawaiiCatDecorationProps {
2 | color: string;
3 | }
4 | interface RetroThemeDecorationProps {
5 | color: string;
6 | }
7 | interface DarkEmeraldDecorationProps {
8 | color: string;
9 | rating: string;
10 | }
11 |
12 | interface WatchdogGradientDecorationProps {
13 | color: string;
14 | rating: string;
15 | }
16 |
17 | export function KawaiiCatDecoration({ color }: KawaiiCatDecorationProps) {
18 | return (
19 |
72 | )
73 | }
74 |
75 | export function RetroThemeDecoration({ color }: RetroThemeDecorationProps) {
76 | return (
77 |
132 | )
133 | }
134 |
135 | export function DarkEmeraldDecoration({ color, rating }: DarkEmeraldDecorationProps) {
136 | let endColor=''
137 | switch(rating) {
138 | case 'S':
139 | endColor = '#1e1b4b'
140 | break
141 | case 'A':
142 | endColor = '#052e16'
143 | break
144 | case 'B':
145 | endColor = '#1a2e05'
146 | break
147 | case 'C':
148 | endColor = '#431407'
149 | break
150 | case 'D':
151 | endColor = '#450a0a'
152 | break
153 | default:
154 | endColor = '#030712'
155 | break
156 | }
157 | return (
158 |
200 | )
201 | }
202 |
203 | export function WatchdogGradientDecoration({ color, rating }: WatchdogGradientDecorationProps) {
204 | let endColor=''
205 | switch(rating) {
206 | case 'S':
207 | endColor = '#1e1b4b'
208 | break
209 | case 'A':
210 | endColor = '#052e16'
211 | break
212 | case 'B':
213 | endColor = '#1a2e05'
214 | break
215 | case 'C':
216 | endColor = '#431407'
217 | break
218 | case 'D':
219 | endColor = '#450a0a'
220 | break
221 | default:
222 | endColor = '#030712'
223 | break
224 | }
225 | return (
226 |
268 | )
269 | }
--------------------------------------------------------------------------------
/scripts/generate.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import { fileURLToPath } from 'node:url'
3 | import { join } from 'node:path'
4 | import satori from 'satori'
5 | import sharp from 'sharp'
6 | import { OGCard, OGCardProps } from '../src/OGCard'
7 | import { Rating } from '../src/rating'
8 | import { preset } from '../src/theme'
9 |
10 |
11 | interface CliOptions {
12 | theme?: keyof typeof preset;
13 | output?: string;
14 | user?: string;
15 | avatar?: string;
16 | devType?: string;
17 | contributor?: boolean;
18 | format?: 'svg' | 'png';
19 | grid?: boolean;
20 | }
21 |
22 | function parseArgs(): CliOptions {
23 | const args = process.argv.slice(2)
24 | const options: CliOptions = {}
25 |
26 | for (let i = 0; i < args.length; i++) {
27 | const arg = args[i]
28 | switch (arg) {
29 | case '--theme':
30 | case '-t': {
31 | options.theme = args[++i] as keyof typeof preset
32 | break
33 | }
34 | case '--output':
35 | case '-o': {
36 | options.output = args[++i]
37 | break
38 | }
39 | case '--user':
40 | case '-u': {
41 | options.user = args[++i]
42 | break
43 | }
44 | case '--avatar':
45 | case '-a': {
46 | options.avatar = args[++i]
47 | break
48 | }
49 | case '--dev-type':
50 | case '-d': {
51 | options.devType = args[++i]
52 | break
53 | }
54 | case '--contributor':
55 | case '-c': {
56 | options.contributor = true
57 | break
58 | }
59 | case '--format':
60 | case '-f': {
61 | const format = args[++i].toLowerCase()
62 | if (format !== 'svg' && format !== 'png') {
63 | console.error('Error: Format must be either "svg" or "png"')
64 | process.exit(1)
65 | }
66 | options.format = format as 'svg' | 'png'
67 | break
68 | }
69 | case '--grid':
70 | case '-g': {
71 | options.grid = true
72 | options.format = 'png' // Force PNG for grid mode
73 | break
74 | }
75 | case '--help':
76 | case '-h': {
77 | printHelp()
78 | process.exit(0)
79 | }
80 | }
81 | }
82 |
83 | // Detect format from output filename if not explicitly specified
84 | if (!options.format && options.output) {
85 | const ext = options.output.toLowerCase().split('.').pop()
86 | if (ext === 'png') options.format = 'png'
87 | else if (ext === 'svg') options.format = 'svg'
88 | }
89 |
90 | options.format = options.format || 'svg'
91 |
92 | return options
93 | }
94 |
95 | function printHelp() {
96 | console.log(`
97 | Usage: generate [options]
98 |
99 | Options:
100 | -t, --theme Theme to use (light/dark)
101 | -o, --output Output file path (defaults to stdout)
102 | -u, --user GitHub username
103 | -a, --avatar Avatar URL
104 | -d, --dev-type Developer type
105 | -c, --contributor Mark as contributor
106 | -f, --format Output format (svg/png)
107 | -g, --grid Generate a grid of all ratings
108 | -h, --help Show this help message
109 | `)
110 | }
111 |
112 | async function generateCard(props: OGCardProps) {
113 | const fontData = fs.readFileSync(fileURLToPath(join(import.meta.url, '../../public/noto-sans-v27-latin-regular.ttf')))
114 |
115 | return await satori(OGCard(props), {
116 | width: 1200,
117 | height: 675,
118 | fonts: [
119 | {
120 | name: 'sans serif',
121 | data: fontData,
122 | weight: 700,
123 | style: 'normal',
124 | },
125 | ],
126 | })
127 | }
128 |
129 | async function generateNoticeCard() {
130 | const svg = `
131 |
143 | `
144 | return svg
145 | }
146 |
147 | async function generateGrid(baseProps: OGCardProps) {
148 | const ratings = [Rating.S, Rating.A, Rating.B, Rating.C, Rating.D]
149 | const scores = {
150 | [Rating.S]: '9.00',
151 | [Rating.A]: '7.50',
152 | [Rating.B]: '6.00',
153 | [Rating.C]: '4.50',
154 | [Rating.D]: '3.00',
155 | }
156 | const cdfs = {
157 | [Rating.S]: '99',
158 | [Rating.A]: '85',
159 | [Rating.B]: '65',
160 | [Rating.C]: '35',
161 | [Rating.D]: '15',
162 | }
163 |
164 | // Generate all rating cards
165 | const ratingCards = await Promise.all(
166 | ratings.map(async (rating) => {
167 | const props = {
168 | ...baseProps,
169 | overallRating: rating,
170 | overallScore: scores[rating],
171 | overallScoreCDF: cdfs[rating],
172 | }
173 | const svg = await generateCard(props)
174 | return sharp(Buffer.from(svg)).toFormat('png').toBuffer()
175 | })
176 | )
177 |
178 | // Generate simple notice card
179 | const noticeSvg = await generateNoticeCard()
180 | const noticeCard = await sharp(Buffer.from(noticeSvg)).toFormat('png').toBuffer()
181 |
182 | // Combine all cards including the notice
183 | const cards = [noticeCard, ...ratingCards]
184 |
185 | const GAP = 20
186 | const COLUMNS = 2
187 | const CARD_WIDTH = 1200
188 | const CARD_HEIGHT = 675
189 | const ROWS = Math.ceil(cards.length / COLUMNS)
190 |
191 | const totalWidth = CARD_WIDTH * COLUMNS + GAP * (COLUMNS - 1)
192 | const totalHeight = CARD_HEIGHT * ROWS + GAP * (ROWS - 1)
193 |
194 | return await sharp({
195 | create: {
196 | width: totalWidth,
197 | height: totalHeight,
198 | channels: 4,
199 | background: { r: 255, g: 255, b: 255, alpha: 0 },
200 | },
201 | })
202 | .composite(
203 | cards.map((buffer, index) => {
204 | const row = Math.floor(index / COLUMNS)
205 | const col = index % COLUMNS
206 | return {
207 | input: buffer,
208 | top: row * (CARD_HEIGHT + GAP),
209 | left: col * (CARD_WIDTH + GAP),
210 | }
211 | })
212 | )
213 | .png()
214 | .toBuffer()
215 | }
216 |
217 | async function main() {
218 | const options = parseArgs()
219 | const user = options.user || 'monatheoctocat'
220 |
221 | const baseProps = {
222 | user,
223 | avatar: options.avatar || `https://github.com/${user}.png`,
224 | devType: options.devType || 'Exemplary Demo Developer',
225 | reliabilityScore: 1.0,
226 | securityScore: 3.0,
227 | maintainabilityScore: 5.0,
228 | contributor: options.contributor ?? true,
229 | regionalRank: [1, 'TW'] as [number, string],
230 | campusRank: [10, 'ntnu'] as [number, string],
231 | theme: preset[options.theme as keyof typeof preset] || preset.light,
232 | overallScore: '9.05',
233 | overallScoreCDF: '99',
234 | overallRating: Rating.S,
235 | }
236 |
237 | if (options.grid) {
238 | const gridBuffer = await generateGrid(baseProps)
239 | if (options.output) {
240 | fs.writeFileSync(options.output, gridBuffer)
241 | } else {
242 | process.stdout.write(gridBuffer)
243 | }
244 | return
245 | }
246 |
247 | const svg = await generateCard(baseProps)
248 |
249 | if (options.format === 'png') {
250 | const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer()
251 |
252 | if (options.output) {
253 | fs.writeFileSync(options.output, pngBuffer)
254 | } else {
255 | process.stdout.write(pngBuffer)
256 | }
257 | } else {
258 | if (options.output) {
259 | fs.writeFileSync(options.output, svg)
260 | } else {
261 | console.log(svg)
262 | }
263 | }
264 | }
265 |
266 | main().catch((err) => {
267 | console.error('Error:', err)
268 | process.exit(1)
269 | })
270 |
--------------------------------------------------------------------------------
/src/theme.ts:
--------------------------------------------------------------------------------
1 | import { Rating } from './rating'
2 |
3 |
4 | export interface Theme {
5 | backgroundColor: string;
6 | textColor: string;
7 | textColorSecondary: string;
8 | badgeColors: Record;
9 | badgeTextColors: Record;
10 | barBackground: string;
11 | barForeground: string;
12 | borderColor: string;
13 | avatarPlaceholderColor: string;
14 | logoColor: string;
15 | }
16 |
17 | const light = {
18 | backgroundColor: '#fff',
19 | textColor: '#000',
20 | textColorSecondary: 'rgba(0, 0, 0, 0.6)',
21 | badgeColors: {
22 | [Rating.S]: '#c4b5fd',
23 | [Rating.A]: '#bbf7d0',
24 | [Rating.B]: '#d9f99d',
25 | [Rating.C]: '#fef08a',
26 | [Rating.D]: '#fed7aa',
27 | [Rating.E]: '#fecaca',
28 | },
29 | badgeTextColors: {
30 | [Rating.S]: '#000',
31 | [Rating.A]: '#000',
32 | [Rating.B]: '#000',
33 | [Rating.C]: '#000',
34 | [Rating.D]: '#000',
35 | [Rating.E]: '#000',
36 | },
37 | barBackground: '#F4F4F5',
38 | barForeground: '#18181B',
39 | borderColor: '#E4E4E7',
40 | avatarPlaceholderColor: '#9ca3af',
41 | logoColor: '#030303',
42 | }
43 |
44 | const dark = {
45 | backgroundColor: '#18181B',
46 | textColor: '#fff',
47 | textColorSecondary: 'rgba(255, 255, 255, 0.6)',
48 | badgeColors: {
49 | [Rating.S]: '#7c3aed',
50 | [Rating.A]: '#16a34a',
51 | [Rating.B]: '#65a30d',
52 | [Rating.C]: '#ca8a04',
53 | [Rating.D]: '#ea580c',
54 | [Rating.E]: '#dc2626',
55 | },
56 | badgeTextColors: {
57 | [Rating.S]: '#fff',
58 | [Rating.A]: '#fff',
59 | [Rating.B]: '#fff',
60 | [Rating.C]: '#fff',
61 | [Rating.D]: '#fff',
62 | [Rating.E]: '#fff',
63 | },
64 | barBackground: '#27272A',
65 | barForeground: '#fff',
66 | borderColor: '#27272A',
67 | avatarPlaceholderColor: '#52525B',
68 | logoColor: '#fff',
69 | }
70 |
71 | const sepia = {
72 | backgroundColor: '#f4ecd8',
73 | textColor: '#5b4636',
74 | textColorSecondary: 'rgba(91, 70, 54, 0.6)',
75 | badgeColors: {
76 | [Rating.S]: '#d2b48c',
77 | [Rating.A]: '#f0e68c',
78 | [Rating.B]: '#eedd82',
79 | [Rating.C]: '#ffd700',
80 | [Rating.D]: '#daa520',
81 | [Rating.E]: '#cd853f',
82 | },
83 | badgeTextColors: {
84 | [Rating.S]: '#5b4636',
85 | [Rating.A]: '#5b4636',
86 | [Rating.B]: '#5b4636',
87 | [Rating.C]: '#5b4636',
88 | [Rating.D]: '#5b4636',
89 | [Rating.E]: '#5b4636',
90 | },
91 | barBackground: '#e8dcc2',
92 | barForeground: '#5b4636',
93 | borderColor: '#c2b280',
94 | avatarPlaceholderColor: '#b4a078',
95 | logoColor: '#5b4636',
96 | }
97 |
98 | const solarizedLight = {
99 | backgroundColor: '#fdf6e3',
100 | textColor: '#657b83',
101 | textColorSecondary: 'rgba(101, 123, 131, 0.6)',
102 | badgeColors: {
103 | [Rating.S]: '#b58900',
104 | [Rating.A]: '#859900',
105 | [Rating.B]: '#2aa198',
106 | [Rating.C]: '#268bd2',
107 | [Rating.D]: '#d33682',
108 | [Rating.E]: '#dc322f',
109 | },
110 | badgeTextColors: {
111 | [Rating.S]: '#002b36',
112 | [Rating.A]: '#002b36',
113 | [Rating.B]: '#002b36',
114 | [Rating.C]: '#fdf6e3',
115 | [Rating.D]: '#fdf6e3',
116 | [Rating.E]: '#fdf6e3',
117 | },
118 | barBackground: '#eee8d5',
119 | barForeground: '#073642',
120 | borderColor: '#93a1a1',
121 | avatarPlaceholderColor: '#93a1a1',
122 | logoColor: '#657b83',
123 | }
124 |
125 | const solarizedDark = {
126 | backgroundColor: '#002b36',
127 | textColor: '#839496',
128 | textColorSecondary: 'rgba(131, 148, 150, 0.6)',
129 | badgeColors: {
130 | [Rating.S]: '#b58900',
131 | [Rating.A]: '#859900',
132 | [Rating.B]: '#2aa198',
133 | [Rating.C]: '#268bd2',
134 | [Rating.D]: '#d33682',
135 | [Rating.E]: '#dc322f',
136 | },
137 | badgeTextColors: {
138 | [Rating.S]: '#002b36',
139 | [Rating.A]: '#002b36',
140 | [Rating.B]: '#002b36',
141 | [Rating.C]: '#002b36',
142 | [Rating.D]: '#002b36',
143 | [Rating.E]: '#002b36',
144 | },
145 | barBackground: '#073642',
146 | barForeground: '#fdf6e3',
147 | borderColor: '#586e75',
148 | avatarPlaceholderColor: '#586e75',
149 | logoColor: '#839496',
150 | }
151 |
152 | const tokyoNight = {
153 | backgroundColor: '#1a1b26',
154 | textColor: '#c0caf5',
155 | textColorSecondary: 'rgba(192, 202, 245, 0.6)',
156 | badgeColors: {
157 | [Rating.S]: '#7aa2f7',
158 | [Rating.A]: '#9ece6a',
159 | [Rating.B]: '#e0af68',
160 | [Rating.C]: '#f7768e',
161 | [Rating.D]: '#ff9e64',
162 | [Rating.E]: '#bb9af7',
163 | },
164 | badgeTextColors: {
165 | [Rating.S]: '#1a1b26',
166 | [Rating.A]: '#1a1b26',
167 | [Rating.B]: '#1a1b26',
168 | [Rating.C]: '#1a1b26',
169 | [Rating.D]: '#1a1b26',
170 | [Rating.E]: '#1a1b26',
171 | },
172 | barBackground: '#1f2335',
173 | barForeground: '#c0caf5',
174 | borderColor: '#3b4261',
175 | avatarPlaceholderColor: '#565f89',
176 | logoColor: '#c0caf5',
177 | }
178 |
179 | const nord = {
180 | backgroundColor: '#2e3440',
181 | textColor: '#d8dee9',
182 | textColorSecondary: 'rgba(216, 222, 233, 0.6)',
183 | badgeColors: {
184 | [Rating.S]: '#88c0d0',
185 | [Rating.A]: '#81a1c1',
186 | [Rating.B]: '#5e81ac',
187 | [Rating.C]: '#a3be8c',
188 | [Rating.D]: '#ebcb8b',
189 | [Rating.E]: '#bf616a',
190 | },
191 | badgeTextColors: {
192 | [Rating.S]: '#2e3440',
193 | [Rating.A]: '#2e3440',
194 | [Rating.B]: '#2e3440',
195 | [Rating.C]: '#2e3440',
196 | [Rating.D]: '#2e3440',
197 | [Rating.E]: '#2e3440',
198 | },
199 | barBackground: '#3b4252',
200 | barForeground: '#d8dee9',
201 | borderColor: '#4c566a',
202 | avatarPlaceholderColor: '#434c5e',
203 | logoColor: '#d8dee9',
204 | }
205 |
206 | const midnight = {
207 | backgroundColor: '#1c1e2d',
208 | textColor: '#d3d7e1',
209 | textColorSecondary: 'rgba(211, 215, 225, 0.7)',
210 | badgeColors: {
211 | [Rating.S]: '#3A506B',
212 | [Rating.A]: '#4C6A92',
213 | [Rating.B]: '#5C7A9D',
214 | [Rating.C]: '#3D4C6D',
215 | [Rating.D]: '#2B3A4A',
216 | [Rating.E]: '#1D2A38',
217 | },
218 | badgeTextColors: {
219 | [Rating.S]: '#ffffff',
220 | [Rating.A]: '#ffffff',
221 | [Rating.B]: '#ffffff',
222 | [Rating.C]: '#ffffff',
223 | [Rating.D]: '#ffffff',
224 | [Rating.E]: '#ffffff',
225 | },
226 | barBackground: '#2c3e50',
227 | barForeground: '#ecf0f1',
228 | borderColor: '#34495e',
229 | avatarPlaceholderColor: '#7f8c8d',
230 | logoColor: '#ecf0f1',
231 | }
232 |
233 | const kawaiiCat = {
234 | backgroundColor: '#F9FFFE',
235 | textColor: '#7A5C58',
236 | textColorSecondary: 'rgba(122, 92, 88, 0.65)',
237 | badgeColors: {
238 | [Rating.S]: '#FFCAD4',
239 | [Rating.A]: '#FFD7DE',
240 | [Rating.B]: '#66B2B2',
241 | [Rating.C]: '#80BFBF',
242 | [Rating.D]: '#99CCCC',
243 | [Rating.E]: '#B3D9D9',
244 | },
245 | badgeTextColors: {
246 | [Rating.S]: '#7A5C58',
247 | [Rating.A]: '#7A5C58',
248 | [Rating.B]: '#FFFFFF',
249 | [Rating.C]: '#FFFFFF',
250 | [Rating.D]: '#7A5C58',
251 | [Rating.E]: '#7A5C58',
252 | },
253 | barBackground: '#FFCAD4',
254 | barForeground: '#66B2B2',
255 | borderColor: '#FFCAD4',
256 | avatarPlaceholderColor: '#B3D9D9',
257 | logoColor: '#FFCAD4',
258 | }
259 |
260 | const retro = {
261 | backgroundColor: '#240046',
262 | textColor: '#f2ebfb',
263 | textColorSecondary: 'rgba(255, 255, 255, 0.6)',
264 | badgeColors: {
265 | [Rating.S]: '#fbe300',
266 | [Rating.A]: '#9cf945',
267 | [Rating.B]: '#4cc9f0',
268 | [Rating.C]: '#9d4edd',
269 | [Rating.D]: '#f72585',
270 | [Rating.E]: '#ff6200',
271 | },
272 | badgeTextColors: {
273 | [Rating.S]: '#240046',
274 | [Rating.A]: '#240046',
275 | [Rating.B]: '#240046',
276 | [Rating.C]: '#240046',
277 | [Rating.D]: '#240046',
278 | [Rating.E]: '#240046',
279 | },
280 | barBackground: '#F4F4F5',
281 | barForeground: '#9d4edd',
282 | borderColor: '#E4E4E7',
283 | avatarPlaceholderColor: '#9ca3af',
284 | logoColor: '#ebd9fc',
285 | }
286 | const darkEmerald = {
287 | backgroundColor: 'linear-gradient(to top left, #00bc7d, #1a1a24, #1a1a24)',
288 | textColor: '#ffffffff',
289 | textColorSecondary: '#22c55e',
290 | badgeColors: {
291 | [Rating.S]: '#a78bfa',
292 | [Rating.A]: '#4ade80',
293 | [Rating.B]: '#a3e635',
294 | [Rating.C]: '#fb923c',
295 | [Rating.D]: '#f87171',
296 | [Rating.E]: '#6b7280',
297 | },
298 | badgeTextColors: {
299 | [Rating.S]: '#0a0a0a',
300 | [Rating.A]: '#0a0a0a',
301 | [Rating.B]: '#0a0a0a',
302 | [Rating.C]: '#0a0a0a',
303 | [Rating.D]: '#0a0a0a',
304 | [Rating.E]: '#fff',
305 | },
306 | barBackground: '#F4F4F5',
307 | barForeground: '#00bc7d',
308 | borderColor: '#1cab90',
309 | avatarPlaceholderColor: '#9ca3af',
310 | logoColor: '#00bc7d',
311 | }
312 |
313 | const dracula = {
314 | backgroundColor: '#282A36',
315 | textColor: '#F8F8F2',
316 | textColorSecondary: '#6272A4',
317 | badgeColors: {
318 | [Rating.S]: '#BD93F9',
319 | [Rating.A]: '#50FA7B',
320 | [Rating.B]: '#F1FA8C',
321 | [Rating.C]: '#FFB86C',
322 | [Rating.D]: '#FF79C6',
323 | [Rating.E]: '#FF5555',
324 | },
325 | badgeTextColors: {
326 | [Rating.S]: '#282A36',
327 | [Rating.A]: '#282A36',
328 | [Rating.B]: '#282A36',
329 | [Rating.C]: '#282A36',
330 | [Rating.D]: '#282A36',
331 | [Rating.E]: '#282A36',
332 | },
333 | barBackground: '#44475A',
334 | barForeground: '#8BE9FD',
335 | borderColor: '#44475A',
336 | avatarPlaceholderColor: '#6272A4',
337 | logoColor: '#F8F8F2',
338 | }
339 |
340 | const WatchdogGradient = {
341 | backgroundColor: 'linear-gradient(to top left, #520806, #021D4A, #520806)',
342 | textColor: '#ffffffff',
343 | textColorSecondary: '#A9FEF7',
344 | badgeColors: {
345 | [Rating.S]: '#a78bfa',
346 | [Rating.A]: '#4ade80',
347 | [Rating.B]: '#a3e635',
348 | [Rating.C]: '#fb923c',
349 | [Rating.D]: '#f87171',
350 | [Rating.E]: '#6b7280',
351 | },
352 | badgeTextColors: {
353 | [Rating.S]: '#021029ff',
354 | [Rating.A]: '#021029ff',
355 | [Rating.B]: '#021029ff',
356 | [Rating.C]: '#021029ff',
357 | [Rating.D]: '#021029ff',
358 | [Rating.E]: '#FF0000',
359 | },
360 | barBackground: '#F4F4F5',
361 | barForeground: '#EB8C30',
362 | borderColor: '#E4E2E2',
363 | avatarPlaceholderColor: '#FE428E',
364 | logoColor: '#EB8C30',
365 | }
366 |
367 | export const preset: Record = {
368 | light,
369 | dark,
370 | sepia,
371 | solarizedLight,
372 | solarizedDark,
373 | tokyoNight,
374 | nord,
375 | midnight,
376 | kawaiiCat,
377 | retro,
378 | darkEmerald,
379 | dracula,
380 | WatchdogGradient
381 | }
382 |
--------------------------------------------------------------------------------
/src/OGCard.tsx:
--------------------------------------------------------------------------------
1 | import { Rating } from './rating'
2 | import { preset, type Theme } from './theme'
3 | import { GitRollLogo } from './logo'
4 | import { WatchdogGradientDecoration, DarkEmeraldDecoration, KawaiiCatDecoration, RetroThemeDecoration } from './decorations'
5 |
6 |
7 | export interface OGCardProps {
8 | user: string
9 | avatar: string | null
10 | devType: string | null
11 | overallScore: string
12 | overallScoreCDF: string
13 | overallRating: Rating
14 | reliabilityScore: number
15 | securityScore: number
16 | maintainabilityScore: number
17 | contributor: boolean
18 | regionalRank?: [ string | number, string ] | null
19 | campusRank?: [ string | number, string ] | null
20 | theme?: Theme
21 | }
22 |
23 | export function OGCard({
24 | user, avatar, devType,
25 | overallScore, overallScoreCDF, overallRating,
26 | reliabilityScore, securityScore, maintainabilityScore,
27 | contributor,
28 | regionalRank, campusRank,
29 | theme = preset.light,
30 | }: OGCardProps) {
31 | const bg = theme.badgeColors[overallRating] ?? theme.badgeColors[Rating.E]
32 | return (
33 |
47 | {theme === preset.kawaiiCat && (
48 |
49 | )}
50 | {theme === preset.retro && (
51 |
52 | )}
53 |
54 |
63 | {avatar ? (
64 |

72 | ) : (
73 |
82 | )}
83 |
89 |
97 | {user}
98 |
99 |
106 | {devType}
107 |
108 |
109 |
110 |
118 |
127 |
133 | Overall Rating
134 |
135 |
143 |
156 | {theme === preset.darkEmerald && (
) || theme === preset.WatchdogGradient && ()}
157 |
165 | {overallRating}
166 |
167 |
168 |
174 | {overallScore}
175 |
176 |
177 |
184 | Above
185 |
189 | {overallScoreCDF}%
190 |
191 | of people
192 |
193 |
194 |
202 |
208 | Code Quality
209 |
210 |
219 |
225 | Reliability
226 |
227 |
252 |
253 |
262 |
268 | Security
269 |
270 |
295 |
296 |
305 |
311 | Maintainability
312 |
313 |
338 |
339 |
340 |
341 |
350 | {contributor && (
351 |
362 | Open-source contributor
363 |
364 | )}
365 | {regionalRank && (
366 |
379 | Top {regionalRank[0]}% in {regionalRank[1]}
380 |
381 | )}
382 | {campusRank && (
383 |
396 | Top {campusRank[0]}% in {campusRank[1]}
397 |
398 | )}
399 |
400 |
401 | )
402 | }
403 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import './index.css'
2 | import React, { useEffect, useState } from 'react'
3 | import ReactDOM from 'react-dom/client'
4 | import satori from 'satori'
5 | import { OGCard } from './OGCard'
6 | import { Rating } from './rating'
7 | import { preset, type Theme } from './theme'
8 |
9 |
10 | const InputGroup = ({ children }: { children: React.ReactNode }) => {children}
11 |
12 | const inputStyle = {
13 | padding: '0.5rem',
14 | borderRadius: '6px',
15 | border: '1px solid #ddd',
16 | fontSize: '1rem',
17 | outline: 'none',
18 | transition: 'border-color 0.2s',
19 | ':focus': {
20 | borderColor: '#0066ff',
21 | },
22 | }
23 |
24 | const selectStyle = {
25 | ...inputStyle,
26 | backgroundColor: 'white',
27 | cursor: 'pointer',
28 | }
29 |
30 | async function loadFont() {
31 | const fontResponse = await fetch(new URL('../noto-sans-v27-latin-regular.ttf', import.meta.url).href)
32 | return await fontResponse.arrayBuffer()
33 | }
34 |
35 | const ColorInput = ({ value, onChange }: { label: string; value: string; onChange: (value: string) => void }) => (
36 |
37 | onChange(e.target.value)}
41 | style={{
42 | width: '40px',
43 | height: '40px',
44 | padding: '0',
45 | border: '1px solid #ddd',
46 | borderRadius: '4px',
47 | cursor: 'pointer',
48 | }}
49 | />
50 | onChange(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
51 |
52 | )
53 |
54 | function App() {
55 | const [, setSvg] = useState('')
56 | const [svgDataUrl, setSvgDataUrl] = useState('')
57 | const [isCustomTheme, setIsCustomTheme] = useState(false)
58 | const [customTheme, setCustomTheme] = useState({
59 | backgroundColor: '#ffffff',
60 | textColor: '#000000',
61 | textColorSecondary: 'rgba(0, 0, 0, 0.6)',
62 | badgeColors: {
63 | [Rating.S]: '#c4b5fd',
64 | [Rating.A]: '#bbf7d0',
65 | [Rating.B]: '#d9f99d',
66 | [Rating.C]: '#fef08a',
67 | [Rating.D]: '#fed7aa',
68 | [Rating.E]: '#fecaca',
69 | },
70 | badgeTextColors: {
71 | [Rating.S]: '#000000',
72 | [Rating.A]: '#000000',
73 | [Rating.B]: '#000000',
74 | [Rating.C]: '#000000',
75 | [Rating.D]: '#000000',
76 | [Rating.E]: '#000000',
77 | },
78 | barBackground: '#F4F4F5',
79 | barForeground: '#18181B',
80 | borderColor: '#E4E4E7',
81 | avatarPlaceholderColor: '#9ca3af',
82 | logoColor: '#030303',
83 | })
84 |
85 | const [props, setProps] = useState({
86 | user: 'GitHub Username',
87 | avatar: 'https://avatars.githubusercontent.com/u/9919?s=200&v=4',
88 | devType: 'Exemplary AI/ML Developer',
89 | overallScore: '9.05',
90 | overallScoreCDF: '99',
91 | overallRating: Rating.S,
92 | reliabilityScore: 4.37,
93 | securityScore: 5.0,
94 | maintainabilityScore: 4.86,
95 | contributor: true,
96 | regionalRank: [1, 'TW'] as [number, string],
97 | campusRank: [1, 'ntnu'] as [number, string],
98 | theme: preset.light,
99 | })
100 |
101 | useEffect(() => {
102 | async function generateSVG() {
103 | const fontData = await loadFont()
104 |
105 | const svg = await satori(OGCard(props), {
106 | width: 1200,
107 | height: 675,
108 | fonts: [
109 | {
110 | name: 'sans serif',
111 | data: fontData,
112 | weight: 700,
113 | style: 'normal',
114 | },
115 | ],
116 | })
117 |
118 | const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
119 | setSvgDataUrl(dataUrl)
120 | setSvg(svg)
121 | }
122 |
123 | generateSVG()
124 | }, [props])
125 |
126 | const handleInputChange = (field: string, value: unknown) => {
127 | setProps((prev) => ({
128 | ...prev,
129 | [field]: value,
130 | }))
131 | }
132 |
133 | return (
134 |
144 |
151 | GitRoll Dev Card Playground
152 |
153 |
154 |
161 |
169 |
176 | Customize Your Card
177 |
178 |
185 |
186 |
187 | handleInputChange('user', e.target.value)} style={inputStyle} />
188 |
189 |
190 |
191 |
192 | handleInputChange('devType', e.target.value)} style={inputStyle} />
193 |
194 |
195 |
196 |
197 | {
204 | const value = Math.min(10, Math.max(0, Number(e.target.value)))
205 | handleInputChange('overallScore', value.toString())
206 | }}
207 | style={inputStyle}
208 | />
209 |
210 |
211 |
212 |
213 | {
219 | const value = Math.min(100, Math.max(0, Number(e.target.value)))
220 | handleInputChange('overallScoreCDF', value.toString())
221 | }}
222 | style={inputStyle}
223 | />
224 |
225 |
226 |
227 |
228 |
235 |
236 |
237 |
238 |
239 | {
246 | const value = Math.min(5, Math.max(0, Number(e.target.value)))
247 | handleInputChange('reliabilityScore', value)
248 | }}
249 | style={inputStyle}
250 | />
251 |
252 |
253 |
254 |
255 | {
262 | const value = Math.min(5, Math.max(0, Number(e.target.value)))
263 | handleInputChange('securityScore', value)
264 | }}
265 | style={inputStyle}
266 | />
267 |
268 |
269 |
270 |
271 | {
278 | const value = Math.min(5, Math.max(0, Number(e.target.value)))
279 | handleInputChange('maintainabilityScore', value)
280 | }}
281 | style={inputStyle}
282 | />
283 |
284 |
285 |
286 |
287 |
288 | {
294 | const value = Math.max(1, parseInt(e.target.value))
295 | handleInputChange('regionalRank', [value, props.regionalRank[1]])
296 | }}
297 | />
298 | handleInputChange('regionalRank', [props.regionalRank[0], e.target.value.toUpperCase()])}
304 | />
305 |
306 |
307 |
308 |
309 |
310 |
311 | {
317 | const value = Math.max(1, parseInt(e.target.value))
318 | handleInputChange('campusRank', [value, props.campusRank[1]])
319 | }}
320 | />
321 | handleInputChange('campusRank', [props.campusRank[0], e.target.value.toLowerCase()])}
327 | />
328 |
329 |
330 |
331 |
332 |
333 |
354 |
355 |
356 |
357 |
358 | handleInputChange('contributor', e.target.checked)}
362 | style={{
363 | width: '20px',
364 | height: '20px',
365 | cursor: 'pointer',
366 | }}
367 | />
368 |
369 |
370 | {isCustomTheme && (
371 | <>
372 |
373 |
374 | {
378 | const updatedTheme = { ...customTheme, backgroundColor: value }
379 | setCustomTheme(updatedTheme)
380 | handleInputChange('theme', updatedTheme)
381 | }}
382 | />
383 |
384 |
385 |
386 |
387 | {
391 | const updatedTheme = { ...customTheme, textColor: value }
392 | setCustomTheme(updatedTheme)
393 | handleInputChange('theme', updatedTheme)
394 | }}
395 | />
396 |
397 |
398 |
399 |
400 | {
404 | const updatedTheme = { ...customTheme, textColorSecondary: value }
405 | setCustomTheme(updatedTheme)
406 | handleInputChange('theme', updatedTheme)
407 | }}
408 | />
409 |
410 |
411 |
412 |
413 | {
417 | const updatedTheme = { ...customTheme, barBackground: value }
418 | setCustomTheme(updatedTheme)
419 | handleInputChange('theme', updatedTheme)
420 | }}
421 | />
422 |
423 |
424 |
425 |
426 | {
430 | const updatedTheme = { ...customTheme, barForeground: value }
431 | setCustomTheme(updatedTheme)
432 | handleInputChange('theme', updatedTheme)
433 | }}
434 | />
435 |
436 |
437 |
438 |
439 | {
443 | const updatedTheme = { ...customTheme, borderColor: value }
444 | setCustomTheme(updatedTheme)
445 | handleInputChange('theme', updatedTheme)
446 | }}
447 | />
448 |
449 |
450 |
451 |
452 | {
456 | const updatedTheme = { ...customTheme, avatarPlaceholderColor: value }
457 | setCustomTheme(updatedTheme)
458 | handleInputChange('theme', updatedTheme)
459 | }}
460 | />
461 |
462 |
463 |
464 |
465 | {
469 | const updatedTheme = { ...customTheme, logoColor: value }
470 | setCustomTheme(updatedTheme)
471 | handleInputChange('theme', updatedTheme)
472 | }}
473 | />
474 |
475 |
476 | {Object.values(Rating).map((rating) => (
477 |
478 |
479 |
480 | {
484 | const updatedTheme = {
485 | ...customTheme,
486 | badgeColors: {
487 | ...customTheme.badgeColors,
488 | [rating]: value,
489 | },
490 | }
491 | setCustomTheme(updatedTheme)
492 | handleInputChange('theme', updatedTheme)
493 | }}
494 | />
495 |
496 |
497 |
498 |
499 | {
503 | const updatedTheme = {
504 | ...customTheme,
505 | badgeTextColors: {
506 | ...customTheme.badgeTextColors,
507 | [rating]: value,
508 | },
509 | }
510 | setCustomTheme(updatedTheme)
511 | handleInputChange('theme', updatedTheme)
512 | }}
513 | />
514 |
515 |
516 | ))}
517 | >
518 | )}
519 |
520 |
521 |
522 |
531 |
538 | Preview
539 |
540 |
552 | {svgDataUrl ? (
553 |

563 | ) : (
564 |
573 | Loading...
574 |
575 | )}
576 |
577 |
578 |
579 |
580 | )
581 | }
582 |
583 | ReactDOM.createRoot(document.querySelector('#root')!).render(
584 |
585 |
586 |
587 | )
588 |
--------------------------------------------------------------------------------