Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b652f300 | ||
|
|
c7630f2ae4 | ||
|
|
480c26b717 | ||
|
|
3090adee7f | ||
|
|
c86492f9c2 | ||
|
|
c177bd9ec5 | ||
|
|
944857b2a5 | ||
|
|
a9cfee5809 | ||
|
|
c7b6e66dea | ||
|
|
6a35f03faa | ||
|
|
34d08882a7 | ||
|
|
4edf85bdc2 | ||
|
|
ad10495810 | ||
|
|
fbc030f475 | ||
|
|
100090de31 | ||
|
|
ba04189700 | ||
|
|
1abd91eace | ||
|
|
dd28696356 | ||
|
|
ecce10198c | ||
|
|
5d3d3af4da | ||
|
|
5156202340 | ||
|
|
28bb41a488 | ||
|
|
3ff8364844 | ||
|
|
df66b24c6a | ||
|
|
fbd292a03d | ||
|
|
68eddf0f81 | ||
|
|
58d941d63a | ||
|
|
372eaaf058 | ||
|
|
09a72df953 | ||
|
|
587f4589b8 | ||
|
|
259fc7c5bc | ||
|
|
caccc4d69a | ||
|
|
1c6b4c31d9 | ||
|
|
2992f63de2 | ||
|
|
9201b65d82 | ||
|
|
90508f02f3 | ||
|
|
3bda3abee7 | ||
|
|
8f7cc5824b | ||
|
|
1d7ea960f5 | ||
|
|
2b908ad146 | ||
|
|
63ca91e1b1 | ||
|
|
2007f51dc0 | ||
|
|
3258ad09b0 | ||
|
|
4ccec4604b | ||
|
|
b81abe07c4 | ||
|
|
b0100ca48e | ||
|
|
7c7fca420b | ||
|
|
1d9db07ed4 | ||
|
|
4043488ee0 | ||
|
|
807aa453d8 | ||
|
|
6f1ad4b732 | ||
|
|
536199bf4a | ||
|
|
a2bb443789 | ||
|
|
5da671b171 | ||
|
|
ed9d284973 | ||
|
|
748d8c5430 | ||
|
|
fef8e90c86 | ||
|
|
fa82216dfe | ||
|
|
ef3f207dbf | ||
|
|
35fbec8004 | ||
|
|
3ef27f872c | ||
|
|
5557e9c772 | ||
|
|
570fc4aaed | ||
|
|
2ac454a177 | ||
|
|
b837c50885 | ||
|
|
dfb07a69b9 | ||
|
|
41b51dcfd9 | ||
|
|
7687c47d85 | ||
|
|
7917d54fdf | ||
|
|
7771a34790 | ||
|
|
1c266ad51e | ||
|
|
6258d90604 | ||
|
|
6d857d0f34 | ||
|
|
e15deeac76 | ||
|
|
b5d50fc28f | ||
|
|
1d394c7fad | ||
|
|
76e09ce3b0 | ||
|
|
96d141e8c7 | ||
|
|
6310073428 | ||
|
|
1d8aaf0fc8 | ||
|
|
4a2785e286 | ||
|
|
16ea9afebe | ||
|
|
c707e6b7b1 |
@@ -21,4 +21,4 @@ If you believe someone is violating the code of conduct, we ask that you report
|
||||
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
|
||||
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
|
||||
|
||||
Based on the [Django Code of Conduct](https://www.djangoproject.com/conduct/).
|
||||
Based on the [Django Code of Conduct](https://www.djangoproject.com/conduct/).
|
||||
|
||||
+33
-19
@@ -12,6 +12,38 @@ pnpm run dev:frontend
|
||||
|
||||
The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pnpm run test # unit tests
|
||||
pnpm run lint # eslint
|
||||
pnpm run typecheck # tsc
|
||||
```
|
||||
|
||||
The **Backend E2E test** in CI compares the cards your branch renders against the ones served by the preview deployment, which is still on the last commit merged to master.
|
||||
So if your PR changes card output at all, that job goes red until the preview catches up.
|
||||
It's marked `continue-on-error`, so it won't block your PR, but do open it and check the diff is only what you expected.
|
||||
|
||||
If the change to card markup was intentional, update the snapshots:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./packages/core/ run test:update:snapshot
|
||||
pnpm --filter ./apps/backend/ run test:update:snapshot
|
||||
```
|
||||
|
||||
## GraphQL Queries
|
||||
|
||||
The GraphQL queries live in `packages/core/src/graphql/queries/*.graphql`,
|
||||
and their TypeScript types are generated from GitHub's published schema into `packages/core/src/graphql/generated/`.
|
||||
Those generated files are committed, so if you change a query, regenerate them and include the result in your PR:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./packages/core/ run generate-graphql-types
|
||||
```
|
||||
|
||||
CI runs `pnpm --filter ./packages/core/ run check-graphql-types`, which fails if the committed types no longer match the queries.
|
||||
Never edit the generated files by hand — change the `.graphql` file and regenerate.
|
||||
|
||||
## Themes Contribution
|
||||
|
||||
We have stopped the addition of new themes to decrease maintenance efforts. If you are considering contributing your theme just because you are using it personally, then instead of adding it to our theme collection, you can use card [customization options](../docs/advanced_documentation.md#customization).
|
||||
@@ -30,25 +62,7 @@ In short, when you submit changes, your submissions are understood to be under t
|
||||
|
||||
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/stats-organization/github-stats-extended/issues/new/choose). If there is already an open issue for your bug in the upstream repo [github-readme-stats](https://github.com/anuraghazra/github-readme-stats/issues) you don't need to report it here.
|
||||
|
||||
## Frequently Asked Questions (FAQs)
|
||||
|
||||
**Q:** How to hide Jupyter Notebook?
|
||||
|
||||
> **Ans:** &hide=jupyter%20notebook
|
||||
|
||||
**Q:** Language Card is incorrect
|
||||
|
||||
> **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats:
|
||||
>
|
||||
> - <https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174>
|
||||
>
|
||||
> - <https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181>
|
||||
|
||||
**Q:** How to count private stats?
|
||||
|
||||
> see [here](../docs/fork.md#private-contributions-support)
|
||||
|
||||
### Feature Request
|
||||
## Feature Request
|
||||
|
||||
**Great Feature Requests** tend to have:
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Security Policy
|
||||
|
||||
Please report any security vulnerabilities to github.stats.extended@gmail.com. I will try to respond as quickly as possible, but since I am maintaining this project in my free time, please allow some time for a response.
|
||||
Please report any security vulnerabilities to github.stats.extended@gmail.com. I will try to respond as quickly as possible, but since I am maintaining this project in my free time, please allow some time for a response.
|
||||
|
||||
@@ -14,11 +14,11 @@ runs:
|
||||
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js (via input)
|
||||
if: ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
|
||||
- name: Setup Node.js (via .nvmrc)
|
||||
if: ${{ !inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -57,6 +57,11 @@ updates:
|
||||
patterns:
|
||||
- "axios"
|
||||
- "axios-*"
|
||||
graphql:
|
||||
patterns:
|
||||
- "graphql"
|
||||
- "@graphql-codegen/*"
|
||||
- "@octokit/graphql-schema"
|
||||
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -67,8 +67,10 @@ jobs:
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
# Only install chromium since it is the only browser enabled in
|
||||
# apps/frontend/playwright.config.ts. Update this if more projects are added.
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm --filter ./apps/frontend/ run test:e2e
|
||||
@@ -81,11 +83,15 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Compares the cards we render here against the preview deployment,
|
||||
# which is still on the last commit merged on master.
|
||||
# So any PR that changes card output turns this red until the preview catches up.
|
||||
# Worth a look, just not always a bug.
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -106,7 +112,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -120,5 +126,11 @@ jobs:
|
||||
- name: Lint (knip)
|
||||
run: pnpm run lint:knip
|
||||
|
||||
- name: Lint (deduped deps)
|
||||
run: pnpm run lint:deps
|
||||
|
||||
- name: Lint (generated GraphQL types)
|
||||
run: pnpm --filter ./packages/core/ run check-graphql-types
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
+12
-25
@@ -11,50 +11,37 @@ on:
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "0 0 */30 * *"
|
||||
- cron: "45 22 */7 * *"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: write
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
update-languages:
|
||||
if: |
|
||||
github.repository == 'anuraghazra/github-readme-stats' ||
|
||||
github.repository == 'stats-organization/github-readme-stats' ||
|
||||
github.repository == 'stats-organization/github-stats-extended'
|
||||
name: Update supported languages
|
||||
if: github.repository == 'stats-organization/github-stats-extended'
|
||||
name: Generate Language Colors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Run update-languages-json.js script
|
||||
run: pnpm --filter ./packages/core/ run generate-langs-json
|
||||
run: pnpm --filter ./packages/core/ run generate-language-colors
|
||||
|
||||
- name: Create Pull Request if upstream language file is changed
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "feat(backend): update languages JSON"
|
||||
commit-message: "feat(core): update language colors"
|
||||
branch: "update_langs/patch"
|
||||
delete-branch: true
|
||||
title: Update languages JSON
|
||||
body: "The
|
||||
[update-langs](https://github.com/stats-organization/github-stats-extended/actions/workflows/update-langs.yml)
|
||||
action found new/updated languages in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)."
|
||||
title: Update languages colors
|
||||
body: >-
|
||||
The [generate-language-colors](https://github.com/stats-organization/github-stats-extended/actions/workflows/generate-language-colors.yml)
|
||||
workflow detected changes in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml) and regenerated the local language colors JSON file.
|
||||
labels: "ci"
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Generate Theme Readme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "packages/core/src/themes/index.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
generate-theme-doc:
|
||||
runs-on: ubuntu-latest
|
||||
name: Generate theme doc
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: write
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
run: |
|
||||
pnpm --filter ./packages/core/ run theme-readme-gen
|
||||
|
||||
- name: Create Pull Request if themes README has changed
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
commit-message: "feat(backend): update themes README"
|
||||
branch: "update_themes_readme/patch"
|
||||
delete-branch: true
|
||||
title: Update themes README
|
||||
body: "The
|
||||
[generate-theme-doc](https://github.com/stats-organization/github-stats-extended/actions/workflows/generate-theme-doc.yml)
|
||||
action found new/updated languages in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)."
|
||||
labels: "ci"
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Generate Theme Readme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "packages/core/src/themes/index.ts"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "45 22 */7 * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
generate-theme-doc:
|
||||
runs-on: ubuntu-latest
|
||||
name: Generate Theme Readme
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
run: pnpm --filter ./packages/core/ run generate-theme-readme
|
||||
|
||||
- name: Create Pull Request if themes README has changed
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "feat(core): update themes README"
|
||||
branch: "update_themes_readme/patch"
|
||||
delete-branch: true
|
||||
title: Update themes README
|
||||
body: >-
|
||||
The [generate-theme-readme](https://github.com/stats-organization/github-stats-extended/actions/workflows/generate-theme-readme.yml)
|
||||
workflow detected changes in
|
||||
[`packages/core/src/themes/index.ts`](https://github.com/stats-organization/github-stats-extended/blob/master/packages/core/src/themes/index.ts)
|
||||
and regenerated the themes README.
|
||||
labels: "ci"
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -27,4 +27,4 @@ jobs:
|
||||
pnpm --filter @stats-organization/github-readme-stats-core pack --dry-run
|
||||
|
||||
- name: Publish core package to npm
|
||||
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public
|
||||
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public --no-git-checks
|
||||
|
||||
@@ -10,22 +10,29 @@ on:
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "45 * * * *"
|
||||
- cron: "15,45 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
triggerRepeatRecent:
|
||||
repeat-recent-requests:
|
||||
if: github.repository == 'stats-organization/github-stats-extended'
|
||||
name: Trigger server to update cached data.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Make Request
|
||||
id: myRequest
|
||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
||||
with:
|
||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||
method: "POST"
|
||||
timeout: "300000"
|
||||
- name: Show Response
|
||||
run: |
|
||||
echo ${{ steps.myRequest.outputs.response }}
|
||||
echo ${{ steps.myRequest.outputs.status }}
|
||||
args=(
|
||||
--silent --show-error # no progress bar, but still report errors
|
||||
--request POST # the only thing the endpoint checks
|
||||
--location # a redirect would otherwise pass the status check
|
||||
--max-redirs 5 # bounded well below curl's default of 50
|
||||
--speed-limit 1 --speed-time 300 # give up after 300s of no traffic
|
||||
--output response.txt # keep the body out of the status capture
|
||||
--write-out "%{http_code}" # status is all we capture on stdout
|
||||
)
|
||||
status=$(curl "${args[@]}" "https://github-stats-extended.vercel.app/api/repeat-recent")
|
||||
cat response.txt
|
||||
printf '\nstatus: %s\n' "$status"
|
||||
test "$status" -lt 400
|
||||
|
||||
@@ -14,5 +14,5 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
|
||||
@@ -1,5 +1 @@
|
||||
pnpm-lock.yaml
|
||||
|
||||
# https://github.com/stats-organization/github-stats-extended/pull/26#discussion_r2709033732
|
||||
# Avoid prettier format on md files to simplify merge on upstream repo
|
||||
*.md
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"prettier.prettier-vscode",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
|
||||
Vendored
+1
-2
@@ -3,8 +3,7 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": ["Wakatime"]
|
||||
"cSpell.words": ["retryer", "Wakatime"]
|
||||
}
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
<img src="docs/appIcon.svg" width="100px" alt="GitHub Stats Extended Logo" />
|
||||
<h1>GitHub Stats Extended</h1>
|
||||
<p>Dynamically generate GitHub stats for your READMEs.</p>
|
||||
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a>
|
||||
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=light_github" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
This project is an [extended version](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
|
||||
This project is the [extended, actively maintained successor](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
# Table of Contents
|
||||
- [Quick Start](#quick-start)
|
||||
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
|
||||
- [Card Types](#card-types)
|
||||
- [Advanced Customization](#advanced-customization)
|
||||
- [Self-Hosting](#self-hosting)
|
||||
- [Run It Yourself](#run-it-yourself)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
# Quick Start
|
||||
## Quick Start
|
||||
|
||||
- Copy and paste this into your markdown:
|
||||
```markdown
|
||||
[](https://github.com/stats-organization/github-stats-extended)
|
||||
@@ -23,45 +34,97 @@ This project is an [extended version](docs/fork.md) of [github-readme-stats](htt
|
||||
- Change the `?username=` value to your GitHub username.
|
||||
- Done!
|
||||
|
||||
-----------------------
|
||||
---
|
||||
|
||||
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
|
||||
|
||||
# Card Types
|
||||
## Migration from github-readme-stats
|
||||
|
||||
To migrate from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) you only need to change the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`:
|
||||
|
||||
```diff
|
||||
- https://github-readme-stats.vercel.app/api?username=octocat&theme=radical
|
||||
+ https://github-stats-extended.vercel.app/api?username=octocat&theme=radical
|
||||
```
|
||||
|
||||
GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For more details see [Compatibility Notes](docs/fork.md#compatibility-notes).
|
||||
|
||||
## Card Types
|
||||
|
||||
- Show your GitHub statistics:
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=light_github" alt="Anurag's GitHub stats" />
|
||||
</picture>
|
||||
|
||||
- ...your top languages...:
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/top-langs/?username=anuraghazra&langs_count=4&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/top-langs/?username=anuraghazra&langs_count=4&theme=light_github" alt="Top Langs" />
|
||||
</picture>
|
||||
|
||||
- ...and development time:
|
||||
|
||||
[](https://wakatime.com/@alan)
|
||||
<a href="https://wakatime.com/@alan">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/wakatime?username=alan&langs_count=6&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/wakatime?username=alan&langs_count=6&theme=light_github" alt="Alan's WakaTime stats" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- Pin more than 6 repos in your GitHub profile:
|
||||
|
||||
[](https://github.com/anuraghazra/github-readme-stats)
|
||||
<a href="https://github.com/anuraghazra/github-readme-stats">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&theme=dark_github_repocard"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&theme=light_github_repocard" alt="Readme Card" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- Pin Gists in your GitHub profile:
|
||||
|
||||
[](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d)
|
||||
<a href="https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=dark_github_repocard"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=light_github_repocard" alt="Gist Card" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- Customize all the cards:
|
||||
|
||||
[](https://github-stats-extended.vercel.app/api/?username=anuraghazra&show_icons=true&theme=calm&rank_icon=github&include_all_commits=true&custom_title=Anurag's+Stats&disable_animations=true&number_format=long&show=prs_merged_percentage,prs_reviewed)
|
||||
|
||||
# Advanced Customization
|
||||
## Advanced Customization
|
||||
|
||||
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
|
||||
|
||||
# Acknowledgements
|
||||
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of their functionality I added several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend I added to the project is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
||||
## Run It Yourself
|
||||
|
||||
# Self-Hosting
|
||||
Since the GitHub API only allows a limited number of requests per hour, the public instance of GitHub-Stats-Extended at https://github-stats-extended.vercel.app/api could possibly hit the rate limiter. If you host your own instance you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
|
||||
If you want to run GitHub-Stats-Extended on your own, there are two main deployment options: you can use [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) to generate cards in your own GitHub Actions workflow. Or you can self-host GitHub-Stats-Extended on Vercel.
|
||||
|
||||
See [Deploy on your own](docs/deploy.md) for various deployment options.
|
||||
See [Run It Yourself](docs/deploy.md) for detailed instructions.
|
||||
|
||||
# Contributing
|
||||
Contributions are welcome!
|
||||
## Acknowledgements
|
||||
|
||||
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
"devDependencies": {
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"express": "5.2.1",
|
||||
"jsdom": "29.0.2",
|
||||
"jsdom": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"pg": "^8.20.0"
|
||||
"axios": "catalog:default",
|
||||
"pg": "^8.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,6 @@ export async function repeatRecentRequests() {
|
||||
if (urls.length === 0) {
|
||||
console.log("No recent requests found.");
|
||||
} else {
|
||||
await makeRequests(urls, 5);
|
||||
await makeRequests(urls, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const STATS_MOCK_RESPONSE = {
|
||||
followers: { totalCount: 0 },
|
||||
repositories: {
|
||||
totalCount: 1,
|
||||
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
|
||||
nodes: [{ name: REPO, stargazerCount: 1 }],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
@@ -110,9 +110,7 @@ const REPO_MOCK_RESPONSE = {
|
||||
isPrivate: false,
|
||||
isArchived: false,
|
||||
isTemplate: false,
|
||||
stargazers: {
|
||||
totalCount: 1,
|
||||
},
|
||||
stargazerCount: 1,
|
||||
description: "Simple cra test repo.",
|
||||
primaryLanguage: {
|
||||
color: "#f1e05a",
|
||||
@@ -203,7 +201,7 @@ beforeAll(async () => {
|
||||
return [200, STATS_MOCK_RESPONSE];
|
||||
}
|
||||
|
||||
if (query.includes("query userInfo") && variables?.login === USER) {
|
||||
if (query.includes("query topLanguages") && variables?.login === USER) {
|
||||
return [200, TOP_LANGS_MOCK_RESPONSE];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,249 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api contract > should match the gradient-background response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="450" height="195" viewBox="0 0 450 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId">Anurag Hazra's GitHub Stats, Rank: A+</title>
|
||||
<desc id="descId">Total Stars Earned: 14099, Total Commits (last year) : 200, Total PRs: 4000, Total Issues: 340, Contributed to (last year): 51</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #ffffff;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #ffffff;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: #ffffff;
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #ffffff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #ffffff;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #ffffff;
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: 251.32741228718345;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 25.665014275610705;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #2f80ed; }
|
||||
.card-bg { fill: url(#gradient-dark); stroke: #e4e2e2; }
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58;
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #4c71f2;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #2f80ed;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #2f80ed;
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: 251.32741228718345;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 25.665014275610705;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
<linearGradient id="gradient" gradientTransform="rotate(35)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4158d0"/>,<stop offset="50%" stop-color="#c850c0"/>,<stop offset="100%" stop-color="#ffcc70"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="gradient-dark" gradientTransform="rotate(45)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#60A5FA"/>,<stop offset="100%" stop-color="#4ADE80"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" fill="url(#gradient)" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">Anurag Hazra's GitHub Stats</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<g data-testid="rank-circle" transform="translate(365, 47.5)">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40"/>
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40"/>
|
||||
<g class="rank-text">
|
||||
|
||||
<text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
|
||||
A+
|
||||
</text>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
<svg x="0" y="0">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Stars Earned:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="stars">14.1k</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Commits (last year):</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="commits">200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 50)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total PRs:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="prs">4k</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 75)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Issues:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="issues">340</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 100)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Contributed to (last year):</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="contribs">51</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -129,6 +373,7 @@ exports[`Test /api contract > should match the public happy-path response snapsh
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -153,8 +398,13 @@ exports[`Test /api contract > should match the public happy-path response snapsh
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" fill="#fffefe" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -299,6 +549,7 @@ exports[`Test /api contract > should match the public many-params response snaps
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -323,8 +574,13 @@ exports[`Test /api contract > should match the public many-params response snaps
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="539" fill="#0f172a" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="539" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -443,7 +699,7 @@ exports[`Test /api contract > should match the public many-params response snaps
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"graphqlRequest": "{"query":"\\nquery userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection(from: $startTime) {\\n totalCommitContributions\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(\\n first: 1\\n contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]\\n ) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n ...RepoStars\\n }\\n}\\nfragment RepoStars on User {\\n repositories(\\n first: 100\\n after: $after\\n ownerAffiliations: $ownerAffiliations\\n orderBy: {direction: DESC, field: STARGAZERS}\\n ) {\\n totalCount\\n nodes {\\n ...RepoNode\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\\nfragment RepoNode on Repository {\\n name\\n stargazerCount\\n}","variables":{"login":"anuraghazra","after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
|
||||
@@ -51,6 +51,7 @@ exports[`Test /api/gist contract > should match the public happy-path response s
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.icon { fill: #586069 }
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -75,8 +76,13 @@ exports[`Test /api/gist contract > should match the public happy-path response s
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -145,12 +151,25 @@ exports[`Test /api/gist contract > should match the public many-params response
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef;
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #ffabcd;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #ffabcd }
|
||||
.icon { fill: #ff00aa }
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #123456; }
|
||||
.card-bg { fill: #0f172a; stroke: #fedcba; }
|
||||
|
||||
.description {
|
||||
fill: #abcdef;
|
||||
|
||||
}
|
||||
.gray { fill: #abcdef }
|
||||
.icon { fill: #aa00ff }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -175,8 +194,13 @@ exports[`Test /api/gist contract > should match the public many-params response
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="399" fill="#0f172a" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="399" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -215,7 +239,7 @@ exports[`Test /api/gist contract > should match the public many-params response
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\nquery gistInfo($gistName: String!) {\\n viewer {\\n gist(name: $gistName) {\\n description\\n owner {\\n login\\n }\\n stargazerCount\\n forks {\\n totalCount\\n }\\n files {\\n name\\n language {\\n name\\n }\\n size\\n }\\n }\\n }\\n}\\n","variables":{"gistName":"happy-gist-id"}}",
|
||||
"graphqlRequest": "{"query":"\\nquery gistInfo($gistName: String!) {\\n viewer {\\n gist(name: $gistName) {\\n description\\n owner {\\n login\\n }\\n stargazerCount\\n forks {\\n totalCount\\n }\\n files {\\n ...GistFileInfo\\n }\\n }\\n }\\n}\\nfragment GistFileInfo on GistFile {\\n name\\n language {\\n name\\n }\\n size\\n}","variables":{"gistName":"happy-gist-id"}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
|
||||
@@ -56,6 +56,140 @@ exports[`Test /api/pin contract > should match the private non-whitelisted usern
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public archived-repo response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="150" viewBox="0 0 400 150" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2; stroke: #434d58 }
|
||||
.badge text { fill: #434d58 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #586069;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">convoychat</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
|
||||
<g data-testid="badge" class="badge" transform="translate(320, -18)">
|
||||
<rect stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"/>
|
||||
<text x="23" y="-5" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">
|
||||
Archived
|
||||
</text>
|
||||
</g>
|
||||
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable</tspan><tspan dy="1.2em" x="25">React, TypeScript and GraphQL chat app that has enough</tspan><tspan dy="1.2em" x="25">text to wrap across multiple lines in the repository card.</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 75)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
|
||||
</g>
|
||||
</g><g transform="translate(80.53999967575072, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(140.4199994087219, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
|
||||
</g>
|
||||
|
||||
<svg x="0" y="0"><g transform="translate(-3, 98)">
|
||||
|
||||
</g></svg>
|
||||
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -106,7 +240,8 @@ exports[`Test /api/pin contract > should match the public happy-path response sn
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
.badge rect { opacity: 0.2; stroke: #434d58 }
|
||||
.badge text { fill: #434d58 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.stagger {
|
||||
@@ -120,6 +255,7 @@ exports[`Test /api/pin contract > should match the public happy-path response sn
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -144,8 +280,13 @@ exports[`Test /api/pin contract > should match the public happy-path response sn
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -221,14 +362,15 @@ exports[`Test /api/pin contract > should match the public many-params response s
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef;
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #a9fef7;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #a9fef7 }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
.badge rect { opacity: 0.2; stroke: #a9fef7 }
|
||||
.badge text { fill: #a9fef7 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #a9fef7 }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
@@ -236,10 +378,26 @@ exports[`Test /api/pin contract > should match the public many-params response s
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #ff00aa;
|
||||
fill: #f8d847;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #123456; }
|
||||
.card-bg { fill: #193549; stroke: #fedcba; }
|
||||
|
||||
.description {
|
||||
fill: #75eeb2;
|
||||
|
||||
}
|
||||
.gray { fill: #75eeb2 }
|
||||
.badge rect { stroke: #75eeb2 }
|
||||
.badge text { fill: #75eeb2 }
|
||||
.stat { fill: #75eeb2 }
|
||||
.icon { fill: #0480ef; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -264,8 +422,13 @@ exports[`Test /api/pin contract > should match the public many-params response s
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="559" fill="#0f172a" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="559" fill="#141321" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -341,7 +504,7 @@ exports[`Test /api/pin contract > should match the public many-params response s
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
|
||||
"graphqlRequest": "{"query":"\\nquery getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n}\\nfragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazerCount\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n}","variables":{"login":"anuraghazra","repo":"convoychat"}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
|
||||
@@ -138,7 +138,9 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
.progress-background { fill: #ddd; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -163,8 +165,13 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="299" fill="#fffefe" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="299" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -183,7 +190,7 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
<text x="215" y="34" class="lang-name">70.92%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<svg data-testid="lang-progress" width="70.92198581560284%">
|
||||
<rect height="8" fill="#dea584" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
</svg>
|
||||
@@ -196,7 +203,7 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
<text x="215" y="34" class="lang-name">12.77%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<svg data-testid="lang-progress" width="12.76595744680851%">
|
||||
<rect height="8" fill="#0f0" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
</svg>
|
||||
@@ -209,7 +216,7 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
<text x="215" y="34" class="lang-name">8.51%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<svg data-testid="lang-progress" width="8.51063829787234%">
|
||||
<rect height="8" fill="#0ff" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
</svg>
|
||||
@@ -222,7 +229,7 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
<text x="215" y="34" class="lang-name">6.38%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<svg data-testid="lang-progress" width="6.382978723404255%">
|
||||
<rect height="8" fill="#3178c6" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
</svg>
|
||||
@@ -235,7 +242,7 @@ exports[`Test /api/top-langs contract > should match the public happy-path respo
|
||||
<text x="215" y="34" class="lang-name">1.42%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<svg data-testid="lang-progress" width="2%">
|
||||
<rect height="8" fill="#f00" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
</svg>
|
||||
@@ -314,7 +321,18 @@ exports[`Test /api/top-langs contract > should match the public many-params resp
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
.progress-background { fill: #ddd; }
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #123456; }
|
||||
.card-bg { fill: #0f172a; stroke: #e4e2e2; }
|
||||
|
||||
.stat { fill: #abcdef; }
|
||||
.lang-name { fill: #abcdef; }
|
||||
.progress-background { fill: #ddd; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -339,8 +357,13 @@ exports[`Test /api/top-langs contract > should match the public many-params resp
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="419" fill="#0f172a" stroke-opacity="0"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="419" fill="#0f172a" stroke-opacity="0"/>
|
||||
|
||||
|
||||
|
||||
@@ -389,7 +412,7 @@ exports[`Test /api/top-langs contract > should match the public many-params resp
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n # do not fetch forks\\n repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {\\n nodes {\\n name\\n languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {\\n edges {\\n size\\n node {\\n color\\n name\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"graphqlRequest": "{"query":"\\nquery topLanguages($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {\\n nodes {\\n ...TopLanguagesRepository\\n }\\n }\\n }\\n}\\nfragment TopLanguagesRepository on Repository {\\n name\\n languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {\\n edges {\\n ...TopLanguage\\n }\\n }\\n}\\nfragment TopLanguage on LanguageEdge {\\n size\\n node {\\n color\\n name\\n }\\n}","variables":{"login":"anuraghazra","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
|
||||
@@ -109,8 +109,11 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
fill: #2f80ed;
|
||||
}
|
||||
.progress-background { fill: #434d58; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -135,8 +138,13 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -155,9 +163,9 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
<text class="stat" x="350" y="12.5">12 hrs</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="52%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
<rect height="8" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
@@ -168,9 +176,9 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
<text class="stat" x="350" y="12.5">6 hrs 30 mins</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="28%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
<rect height="8" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
@@ -181,9 +189,9 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
<text class="stat" x="350" y="12.5">3 hrs</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="13%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
<rect height="8" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
@@ -194,9 +202,9 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
<text class="stat" x="350" y="12.5">1 hr</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="4%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
<rect height="8" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
@@ -207,9 +215,9 @@ exports[`Test /api/wakatime contract > should match the public happy-path respon
|
||||
<text class="stat" x="350" y="12.5">30 mins</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<rect data-testid="progress-background" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="3%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
<rect height="8" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
@@ -285,8 +293,11 @@ exports[`Test /api/wakatime contract > should match the public inaccessible-prof
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
fill: #2f80ed;
|
||||
}
|
||||
.progress-background { fill: #434d58; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -311,8 +322,13 @@ exports[`Test /api/wakatime contract > should match the public inaccessible-prof
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
@@ -326,7 +342,7 @@ exports[`Test /api/wakatime contract > should match the public inaccessible-prof
|
||||
|
||||
<svg x="0" y="0" width="100%">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
|
||||
<text x="25" y="11" class="stat bold">WakaTime user profile not public</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -353,7 +369,7 @@ exports[`Test /api/wakatime contract > should match the public many-params respo
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
fill: #ff3456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
@@ -398,8 +414,35 @@ exports[`Test /api/wakatime contract > should match the public many-params respo
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
fill: #ff3456;
|
||||
}
|
||||
.progress-background { fill: #abcdef; }
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #ff6543; }
|
||||
.card-bg { fill: #193549; stroke: #fedcba; }
|
||||
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
|
||||
.lang-name { fill: #abcdef }
|
||||
.lang-progress { fill: #ff6543; }
|
||||
.progress-background { fill: #abcdef; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Animations */
|
||||
@@ -424,8 +467,13 @@ exports[`Test /api/wakatime contract > should match the public many-params respo
|
||||
</style>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="619" fill="#0f172a" stroke-opacity="1"/>
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="619" fill="#141321" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
|
||||
@@ -116,6 +116,31 @@ describe("Test /api contract", () => {
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the gradient-background response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
theme_light: "ambient_gradient",
|
||||
bg_color_dark: "45,60A5FA,4ADE80",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
|
||||
@@ -76,8 +76,10 @@ describe("Test /api/gist contract", () => {
|
||||
const params = new URLSearchParams({
|
||||
id: "happy-gist-id",
|
||||
title_color: "123456",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
icon_color_light: "ff00aa",
|
||||
icon_color_dark: "aa00ff",
|
||||
text_color_light: "ffabcd",
|
||||
text_color_dark: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
|
||||
@@ -68,6 +68,32 @@ describe("Test /api/pin contract", () => {
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public archived-repo response snapshot", async () => {
|
||||
let archived_data_user = structuredClone(data_user);
|
||||
archived_data_user.data.user.repository.isArchived = true;
|
||||
|
||||
mock
|
||||
.onPost("https://api.github.com/graphql")
|
||||
.reply(200, archived_data_user);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=anuraghazra&repo=convoychat",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
mock
|
||||
@@ -102,9 +128,8 @@ describe("Test /api/pin contract", () => {
|
||||
username: "anuraghazra",
|
||||
repo: "convoychat",
|
||||
title_color: "123456",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
theme_light: "radical",
|
||||
theme_dark: "cobalt",
|
||||
card_width: "560",
|
||||
show_owner: "true",
|
||||
show: "prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented",
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("Test /api/top-langs contract", () => {
|
||||
title_color: "123456",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
theme_dark: "cobalt",
|
||||
layout: "compact",
|
||||
langs_count: "3",
|
||||
exclude_repo: "repo-hidden",
|
||||
|
||||
@@ -92,9 +92,13 @@ describe("Test /api/wakatime contract", () => {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
theme: "vue",
|
||||
theme_light: "radical",
|
||||
theme_dark: "cobalt",
|
||||
title_color: "123456",
|
||||
title_color_light: "ff3456",
|
||||
title_color_dark: "ff6543",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
card_width: "620",
|
||||
custom_title: "a custom title",
|
||||
layout: "compact",
|
||||
|
||||
@@ -26,9 +26,9 @@ export const data_stats = {
|
||||
repositories: {
|
||||
totalCount: 3,
|
||||
nodes: [
|
||||
{ name: "repo-keep-1", stargazers: { totalCount: 1500 } },
|
||||
{ name: "repo-exclude-me", stargazers: { totalCount: 9999 } },
|
||||
{ name: "repo-keep-2", stargazers: { totalCount: 2600 } },
|
||||
{ name: "repo-keep-1", stargazerCount: 1500 },
|
||||
{ name: "repo-exclude-me", stargazerCount: 9999 },
|
||||
{ name: "repo-keep-2", stargazerCount: 2600 },
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
@@ -73,9 +73,7 @@ export const data_user = {
|
||||
username: "anuraghazra",
|
||||
name: "convoychat",
|
||||
nameWithOwner: "anuraghazra/convoychat",
|
||||
stargazers: {
|
||||
totalCount: 38000,
|
||||
},
|
||||
stargazerCount: 38000,
|
||||
description:
|
||||
"Help us take over the world with a deeply customizable React, TypeScript and GraphQL chat app that has enough text to wrap across multiple lines in the repository card.",
|
||||
primaryLanguage: {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Puts the SPA into an authenticated state without contacting GitHub by
|
||||
* stubbing the OAuth code exchange and the follow-up user-access lookup:
|
||||
* `authenticate` returns a userId (which flips `isAuthenticated` to true), and
|
||||
* `user-access` returns metadata so AppTrends does not immediately log back out.
|
||||
* @param page - The Playwright page to install the route handlers on.
|
||||
*/
|
||||
async function mockAuthEndpoints(page: Page): Promise<void> {
|
||||
await page.route("**/api/authenticate**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ userId: "octocat", needDowngrade: false }),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/user-access**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ token: "test-token", privateAccess: "false" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("AppTrends auth-driven stage transition", () => {
|
||||
test("auto-advances from Login to Select a Card once authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthEndpoints(page);
|
||||
|
||||
// Land on the app as GitHub does after the OAuth redirect (URL carries `code`).
|
||||
await page.goto("?code=test-oauth-code");
|
||||
|
||||
// AppTrends starts unauthenticated on stage 0 ("Login"). When the code
|
||||
// exchange flips `isAuthenticated` false -> true, the render-phase
|
||||
// transition must advance the stepper to stage 1 ("Select a Card").
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Select a Card",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a manually selected earlier step instead of re-forcing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthEndpoints(page);
|
||||
await page.goto("?code=test-oauth-code");
|
||||
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await expect(heading).toContainText("Select a Card");
|
||||
|
||||
// Going back to "Login" while still authenticated must stick: the transition
|
||||
// fires only on an auth *change*, so a later render must not snap the user
|
||||
// forward again. A bug that dropped the previous-value guard would bounce
|
||||
// the heading straight back to "Select a Card" here.
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(heading).toContainText("Login");
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => {
|
||||
// Logo exists
|
||||
await expect(page.locator('img[alt="logo"]')).toBeVisible();
|
||||
|
||||
// Star on GitHub button
|
||||
const starButton = page.getByRole("button", { name: /star on/i });
|
||||
// Star on GitHub link
|
||||
const starButton = page.getByRole("link", { name: /star on/i });
|
||||
await expect(starButton).toBeVisible();
|
||||
|
||||
const githubLink = page.locator(
|
||||
@@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => {
|
||||
await expect(guestBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test("theme picker switches and persists the theme", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
const html = page.locator("html");
|
||||
const trigger = page.getByRole("button", { name: /choose theme/i });
|
||||
const options = page.getByRole("menuitemradio");
|
||||
|
||||
// The menu is closed initially.
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(options).toHaveCount(0);
|
||||
|
||||
// Opening it reveals the options (portaled to body, above the stepper).
|
||||
await trigger.click();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Read the themes off the options instead of hardcoding their names.
|
||||
const firstTheme = await options.first().getAttribute("data-theme-value");
|
||||
const lastTheme = await options.last().getAttribute("data-theme-value");
|
||||
|
||||
// Selecting an option applies the theme, persists it, and closes the menu.
|
||||
await options.last().click();
|
||||
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
|
||||
await expect(options).toHaveCount(0);
|
||||
await expect(
|
||||
page.evaluate(() => localStorage.getItem("theme")),
|
||||
).resolves.toBe(lastTheme);
|
||||
|
||||
// The choice survives a reload and is marked as selected on reopen.
|
||||
await page.reload();
|
||||
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
|
||||
await trigger.click();
|
||||
await expect(options.last()).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Switching to another option works too.
|
||||
await options.first().click();
|
||||
await expect(html).toHaveAttribute("data-theme", firstTheme ?? "");
|
||||
});
|
||||
|
||||
test("navigates between steps", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("selecting 'None' progress style hides the rank circle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("");
|
||||
|
||||
// Stage 0 -> 2: go to customization
|
||||
await page.getByRole("button", { name: "Modify Parameters" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Modify Card Parameters",
|
||||
);
|
||||
|
||||
const preview = page.locator("#svgWrapper");
|
||||
const rankCircle = preview.locator('[data-testid="rank-circle"]');
|
||||
|
||||
// Default "Rank" progress style renders the rank circle.
|
||||
await expect(preview).toBeAttached();
|
||||
await expect(rankCircle).toHaveCount(1);
|
||||
|
||||
await page.getByRole("combobox").selectOption({ label: "None" });
|
||||
|
||||
await expect(rankCircle).toHaveCount(0);
|
||||
});
|
||||
@@ -37,6 +37,24 @@
|
||||
<title>GitHub Stats Extended</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply the persisted theme from localStorage before first paint. This is
|
||||
// what makes a saved theme survive a reload when it differs from the OS
|
||||
// preference (DaisyUI's `--prefersdark` only covers the OS default, and
|
||||
// nothing in React sets `data-theme` on mount).
|
||||
// Also suppress color transitions until after the first paint so elements
|
||||
// don't animate from their unstyled state on load.
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("theme");
|
||||
const matchesDarkTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
var theme = stored || (matchesDarkTheme ? "dark" : "light");
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
|
||||
+14
-14
@@ -13,30 +13,30 @@
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"axios": "^1",
|
||||
"axios-cache-interceptor": "^1",
|
||||
"daisyui": "^5.5.19",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@stats-organization/github-readme-stats-backend": "workspace:^",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "catalog:default",
|
||||
"axios-cache-interceptor": "^1.12.0",
|
||||
"daisyui": "^5.6.18",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"redux": "^5.0.1",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"uuid": "^14.0.0"
|
||||
"uuid": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"vite": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
|
||||
@@ -88,9 +88,7 @@ axios.defaults.adapter = async (config) => {
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes(
|
||||
"query userInfo($login: String!, $after: String, $includeMergedPullRequests:",
|
||||
) &&
|
||||
params.query?.includes("query userInfo(") &&
|
||||
params.variables?.login === DEMO_USER
|
||||
) {
|
||||
return createMockResponse(userStats, config);
|
||||
@@ -98,9 +96,7 @@ axios.defaults.adapter = async (config) => {
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes(
|
||||
"query userInfo($login: String!, $after: String, $ownerAffiliations:",
|
||||
) &&
|
||||
params.query?.includes("query userRepos(") &&
|
||||
params.variables?.login === DEMO_USER
|
||||
) {
|
||||
return createMockResponse(additionalUserStars, config);
|
||||
@@ -108,9 +104,7 @@ axios.defaults.adapter = async (config) => {
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes(
|
||||
"query userInfo($login: String!, $ownerAffiliations:",
|
||||
) &&
|
||||
params.query?.includes("query topLanguages(") &&
|
||||
params.variables?.login === DEMO_USER
|
||||
) {
|
||||
return createMockResponse(topLanguages, config);
|
||||
@@ -118,7 +112,7 @@ axios.defaults.adapter = async (config) => {
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes("fragment RepoInfo on Repository {") &&
|
||||
params.query?.includes("query getRepo(") &&
|
||||
params.variables &&
|
||||
params.variables.login === DEMO_REPO.split("/")[0] &&
|
||||
params.variables.repo === DEMO_REPO.split("/")[1]
|
||||
|
||||
@@ -1,39 +1,76 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
import type { CSSProperties, JSX } from "react";
|
||||
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
import { CardImage } from "./CardImage";
|
||||
import { LIGHT_CARD_BG } from "./themeBackdrop";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
card: CardUrlBuilder;
|
||||
stage: number;
|
||||
selected?: boolean;
|
||||
compact?: boolean;
|
||||
fixedSize?: boolean;
|
||||
/** CSS background for the card surface (e.g. a theme's own background). */
|
||||
backgroundColor?: string;
|
||||
/** Title color, used when the card sits on a custom background. */
|
||||
titleColor?: string;
|
||||
}
|
||||
|
||||
export const Card = ({
|
||||
title,
|
||||
description,
|
||||
imageSrc,
|
||||
card,
|
||||
stage,
|
||||
selected = false,
|
||||
compact = false,
|
||||
fixedSize = false,
|
||||
backgroundColor,
|
||||
titleColor,
|
||||
}: CardProps): JSX.Element => {
|
||||
const customBackground = backgroundColor
|
||||
? selected
|
||||
? `color-mix(in oklab, var(--color-primary) ${backgroundColor === LIGHT_CARD_BG ? "10%" : "25%"}, ${backgroundColor})`
|
||||
: backgroundColor
|
||||
: undefined;
|
||||
|
||||
const cardStyle = customBackground
|
||||
? ({ "--card-bg": customBackground } as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("p-6 rounded border-2", {
|
||||
className={clsx("p-6 rounded border-4", {
|
||||
"h-[370px] w-[510px]": fixedSize,
|
||||
"border-blue-500 bg-blue-50": selected,
|
||||
"border-gray-200 bg-white hover:bg-gray-50": !selected,
|
||||
"border-primary": selected,
|
||||
"border-base-300": !selected,
|
||||
"bg-[var(--card-bg)]": !!customBackground,
|
||||
// Token backgrounds only apply when no custom background is given.
|
||||
"bg-primary/10": selected && !customBackground,
|
||||
"bg-base-100 hover:bg-base-200": !selected && !customBackground,
|
||||
"hover:bg-base-300": !selected && !!customBackground,
|
||||
})}
|
||||
style={cardStyle}
|
||||
data-theme={
|
||||
stage == 3
|
||||
? backgroundColor === LIGHT_CARD_BG || !backgroundColor
|
||||
? "light"
|
||||
: "dark"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
|
||||
<h2
|
||||
className="text-xl font-medium title-font text-base-content"
|
||||
style={titleColor ? { color: titleColor } : undefined}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
|
||||
<CardImage
|
||||
imageSrc={imageSrc}
|
||||
card={card}
|
||||
compact={compact}
|
||||
className={clsx({ "flex justify-center": fixedSize })}
|
||||
stage={stage}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import { HOST } from "../../constants";
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
import { SvgInline } from "./SvgInline";
|
||||
|
||||
interface CardImageProps {
|
||||
imageSrc: string;
|
||||
card: CardUrlBuilder;
|
||||
stage: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardImage = ({
|
||||
imageSrc,
|
||||
card,
|
||||
stage,
|
||||
compact = false,
|
||||
className,
|
||||
}: CardImageProps) => {
|
||||
const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`;
|
||||
// `client=wizard` marks requests coming from the wizard preview.
|
||||
const fullImageSrc = card.client("wizard").toApiUrl(HOST);
|
||||
|
||||
return (
|
||||
<div className={clsx("w-full relative", className)}>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
|
||||
FETCH_MULTI_PAGE_STARS: "10",
|
||||
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
|
||||
loadConfigFromEnv(config);
|
||||
|
||||
setLoaded(false);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { themes } from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
// We use the same background colors here which GitHub uses.
|
||||
export const LIGHT_CARD_BG = "#ffffff";
|
||||
const DARK_CARD_BG = "#0d1117";
|
||||
|
||||
// Themes that look good in either mode (their text/icon colors read on both a
|
||||
// light and a dark backdrop), so their backdrop follows the app theme. Note we
|
||||
// only list themes that are genuinely mode-agnostic here: other transparent
|
||||
// themes (e.g. shadow_*) have dark text and must stay on a light backdrop.
|
||||
const ADAPTIVE_THEMES = ["transparent", "ambient_gradient"];
|
||||
|
||||
/** Expand a shorthand `#abc` hex to `#aabbcc`. */
|
||||
function expandHex(hex: string): string {
|
||||
if (hex.length === 3) {
|
||||
return hex
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/** Whether a hex color (3-, 6- or 8-digit) is dark by perceived luminance. */
|
||||
function isDarkHex(hex: string): boolean {
|
||||
const normalized = expandHex(hex);
|
||||
if (normalized.length < 6) {
|
||||
return false;
|
||||
}
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
const g = parseInt(normalized.slice(2, 4), 16);
|
||||
const b = parseInt(normalized.slice(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance < 0.5;
|
||||
}
|
||||
|
||||
/** Whether a theme's `bg_color` (hex or `angle,c1,c2,…` gradient) is dark. */
|
||||
function isDarkBg(bgColor: string): boolean {
|
||||
const parts = bgColor.split(",");
|
||||
// For gradients use the first color stop (index 0 is the angle).
|
||||
return isDarkHex((parts.length > 1 ? parts[1] : parts[0]) ?? "");
|
||||
}
|
||||
|
||||
/** A theme whose backdrop should follow the app mode rather than its own bg. */
|
||||
function isAdaptiveTheme(themeName: string): boolean {
|
||||
return ADAPTIVE_THEMES.includes(themeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the backdrop a card actually gets is dark, for the given app mode.
|
||||
* Adaptive themes follow the app mode; the rest follow their own background.
|
||||
*/
|
||||
function isCardBackdropDark(themeName: string, appIsDark: boolean): boolean {
|
||||
const theme = themes[themeName as keyof typeof themes];
|
||||
if (isAdaptiveTheme(themeName)) {
|
||||
return appIsDark;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!theme) {
|
||||
return appIsDark;
|
||||
}
|
||||
return isDarkBg(theme.bg_color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort rank for the theme grid: light themes first (0), adaptive themes in the
|
||||
* middle (1), dark themes last (2). Adaptive cards sit between the groups so
|
||||
* they blend with whichever side matches their backdrop in the current mode.
|
||||
*/
|
||||
export function getThemeSortRank(themeName: string): number {
|
||||
if (isAdaptiveTheme(themeName)) {
|
||||
return 1;
|
||||
}
|
||||
const theme = themes[themeName as keyof typeof themes];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!theme) {
|
||||
return 0;
|
||||
}
|
||||
return isDarkBg(theme.bg_color) ? 2 : 0;
|
||||
}
|
||||
|
||||
/** The backdrop color a card rendered with the given theme should sit on. */
|
||||
export function getCardThemeBackdrop(
|
||||
themeName: string,
|
||||
appIsDark: boolean,
|
||||
): string {
|
||||
return isCardBackdropDark(themeName, appIsDark)
|
||||
? DARK_CARD_BG
|
||||
: LIGHT_CARD_BG;
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { HTMLProps, JSX, ReactNode } from "react";
|
||||
|
||||
interface ButtonProps extends HTMLProps<HTMLButtonElement> {
|
||||
type ButtonVariant = "primary" | "soft" | "error";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, "size"> {
|
||||
children: ReactNode;
|
||||
/** DaisyUI color variant. Omit for the default neutral button. */
|
||||
variant?: ButtonVariant | undefined;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps): JSX.Element {
|
||||
const { className, children, ...rest } = props;
|
||||
const { className, children, variant, size = "md", ...rest } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
|
||||
"btn text-lg",
|
||||
{
|
||||
"btn-primary": variant === "primary",
|
||||
"btn-soft": variant === "soft",
|
||||
"btn-error": variant === "error",
|
||||
"btn-sm": size === "sm",
|
||||
"btn-lg": size === "lg",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Checkbox({
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
className="checkbox bg-white text-[#18181b] mr-2"
|
||||
className="checkbox checkbox-primary mr-2"
|
||||
onChange={() => {
|
||||
onCheckedChange(!checked);
|
||||
}}
|
||||
|
||||
@@ -27,10 +27,7 @@ export function Select({
|
||||
}: SelectProps): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
className={clsx(
|
||||
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
|
||||
className,
|
||||
)}
|
||||
className={clsx("text-base select select-sm w-40 mt-4", className)}
|
||||
value={selectedOption.value}
|
||||
onChange={(e) => {
|
||||
onOptionChange(options[e.target.selectedIndex] as SelectOption);
|
||||
@@ -43,7 +40,7 @@ export function Select({
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={clsx({
|
||||
"bg-blue-200": option.value === selectedOption.value,
|
||||
"bg-primary/20": option.value === selectedOption.value,
|
||||
})}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
FaCheck as CheckIcon,
|
||||
FaMoon as MoonIcon,
|
||||
FaSun as SunIcon,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||
import { THEMES } from "../../redux/slices/theme";
|
||||
|
||||
interface ThemeIconProps {
|
||||
isDark: boolean;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/** Sun for light themes, moon for dark ones. */
|
||||
function ThemeIcon({ isDark, className }: ThemeIconProps): JSX.Element {
|
||||
return isDark ? (
|
||||
<MoonIcon className={className} />
|
||||
) : (
|
||||
<SunIcon className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemePicker(): JSX.Element {
|
||||
const { theme, isDark, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, right: 0 });
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!open && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
setOpen((value) => !value);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
!menuRef.current?.contains(target) &&
|
||||
!buttonRef.current?.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", close);
|
||||
// Close on scroll so the fixed menu can't detach from its button.
|
||||
window.addEventListener("scroll", close, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-circle not-[&:hover]:text-current"
|
||||
aria-label="Choose theme"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title="Choose theme"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ThemeIcon isDark={isDark} className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<ul
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="menu bg-base-200 text-base-content rounded-box fixed z-[9999] w-48 p-2 shadow-lg"
|
||||
style={{ top: position.top, right: position.right }}
|
||||
>
|
||||
{THEMES.map((option) => (
|
||||
<li key={option.name}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={option.name === theme}
|
||||
data-theme-value={option.name}
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
setTheme(option.name);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ThemeIcon isDark={option.isDark} className="w-4 h-4" />
|
||||
{option.label}
|
||||
</span>
|
||||
{option.name === theme && <CheckIcon className="w-3 h-3" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const options: Array<SelectOption> = [
|
||||
id: 6,
|
||||
label: "Only Languages",
|
||||
disabled: false,
|
||||
value: "compact&hide_progress=true",
|
||||
value: "hide_progress",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
import { useDebouncedField } from "../../hooks/useDebouncedField";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface NumericSectionProps {
|
||||
@@ -26,45 +27,21 @@ export function NumericSection({
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: NumericSectionProps): JSX.Element {
|
||||
const [internalValue, setInternalValue] = useState(() => value?.toString());
|
||||
const debounceTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce onValueChange
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
if (internalValue === value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
debounceTimeout.current = window.setTimeout(() => {
|
||||
const maybeNumber = internalValue && parseInt(internalValue, 10);
|
||||
if (typeof maybeNumber !== "number" || Number.isNaN(maybeNumber)) {
|
||||
onValueChange(undefined);
|
||||
} else {
|
||||
onValueChange(maybeNumber);
|
||||
}
|
||||
}, 700);
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimeout.current as number);
|
||||
};
|
||||
}, [internalValue, onValueChange, value]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value?.toString());
|
||||
}, [value]);
|
||||
const { inputValue, setInputValue } = useDebouncedField({
|
||||
value,
|
||||
onValueChange,
|
||||
type: "number",
|
||||
});
|
||||
|
||||
return (
|
||||
<Section title={title}>
|
||||
<p>{description}</p>
|
||||
<input
|
||||
type="number"
|
||||
className="border border-gray-300 bg-white rounded px-2 py-1 mt-2 w-1/4"
|
||||
value={internalValue ?? ""}
|
||||
className="border border-base-content/20 rounded px-2 py-1 mt-2 w-1/4 bg-base-100"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInternalValue(e.target.value);
|
||||
setInputValue(e.target.value);
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
|
||||
@@ -24,7 +24,7 @@ function ProgressSection({
|
||||
<button
|
||||
className={clsx(
|
||||
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
|
||||
passed ? "border-blue-500" : "border-gray-500",
|
||||
passed ? "border-primary" : "border-base-content/60",
|
||||
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
|
||||
)}
|
||||
type="button"
|
||||
@@ -32,14 +32,16 @@ function ProgressSection({
|
||||
>
|
||||
<div
|
||||
className={clsx("text-lg font-bold", {
|
||||
"text-blue-500": passed,
|
||||
"text-gray-500": !passed,
|
||||
"text-primary": passed,
|
||||
"text-base-content/60": !passed,
|
||||
"-mt-[4px]": isActive,
|
||||
})}
|
||||
>
|
||||
{`Step ${num + 1}`}
|
||||
</div>
|
||||
<div className={passed ? "text-gray-700" : "text-gray-500"}>{item}</div>
|
||||
<div className={passed ? "text-base-content/80" : "text-base-content/60"}>
|
||||
{item}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -59,16 +61,23 @@ export function ProgressBar({
|
||||
const rightDisabled = currItemIndex === items.length - 1;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center sticky top-0 bg-gray-200 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
|
||||
<LeftArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-gray-400 cursor-not-allowed": leftDisabled,
|
||||
"text-gray-700 cursor-pointer": !leftDisabled,
|
||||
})}
|
||||
<div className="w-full flex items-center sticky top-0 bg-base-300 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous step"
|
||||
disabled={leftDisabled}
|
||||
className="btn btn-ghost btn-circle"
|
||||
onClick={() => {
|
||||
onItemClick(Math.max(currItemIndex - 1, 0));
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<LeftArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-base-content/30": leftDisabled,
|
||||
"text-base-content/70": !leftDisabled,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
<div className="px-2 flex-grow flex flex-row">
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
@@ -85,15 +94,22 @@ export function ProgressBar({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RightArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-gray-400 cursor-not-allowed": rightDisabled,
|
||||
"text-gray-700 cursor-pointer": !rightDisabled,
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next step"
|
||||
disabled={rightDisabled}
|
||||
className="btn btn-ghost btn-circle"
|
||||
onClick={() => {
|
||||
onItemClick(Math.min(currItemIndex + 1, items.length - 1));
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<RightArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-base-content/30": rightDisabled,
|
||||
"text-base-content/70": !rightDisabled,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ export function Section({ title, children }: SectionProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex relative pb-12">
|
||||
<div className="h-full w-10 absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-full w-1 bg-gray-200 pointer-events-none" />
|
||||
<div className="h-full w-1 bg-base-300 pointer-events-none" />
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-500 inline-flex items-center justify-center text-white relative z-10">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary inline-flex items-center justify-center text-primary-content relative z-10">
|
||||
<LightningIcon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-grow pl-4">
|
||||
<h2 className="font-medium title-font text-sm text-gray-900 mb-1 tracking-wider">
|
||||
<h2 className="font-medium title-font text-sm text-base-content mb-1 tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
|
||||
@@ -16,7 +16,7 @@ const options: Array<SelectOption> = [
|
||||
DEFAULT_OPTION,
|
||||
{ id: 2, label: "Percentile", value: "percentile", disabled: false },
|
||||
{ id: 3, label: "GitHub", value: "github", disabled: false },
|
||||
{ id: 4, label: "None", value: "default&hide_rank=true", disabled: false },
|
||||
{ id: 4, label: "None", value: "hide_rank", disabled: false },
|
||||
];
|
||||
|
||||
interface StatsRankSectionProps {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
|
||||
|
||||
import { useDebouncedField } from "../../hooks/useDebouncedField";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface TextSectionProps {
|
||||
@@ -25,29 +26,11 @@ export function TextSection({
|
||||
placeholder,
|
||||
onPaste,
|
||||
}: TextSectionProps): JSX.Element {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const debounceTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce onValueChange
|
||||
if (debounceTimeout.current) {
|
||||
window.clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
if (internalValue === value) {
|
||||
return undefined;
|
||||
}
|
||||
debounceTimeout.current = window.setTimeout(() => {
|
||||
onValueChange(internalValue);
|
||||
}, 700);
|
||||
// return cleanup function:
|
||||
return () => {
|
||||
window.clearTimeout(debounceTimeout.current as number);
|
||||
};
|
||||
}, [internalValue, onValueChange, value]);
|
||||
const { inputValue, setInputValue } = useDebouncedField({
|
||||
value,
|
||||
onValueChange,
|
||||
type: "text",
|
||||
});
|
||||
|
||||
return (
|
||||
<Section title={title}>
|
||||
@@ -55,12 +38,12 @@ export function TextSection({
|
||||
<input
|
||||
type="text"
|
||||
className={clsx(
|
||||
"border border-gray-300 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
|
||||
{ "cursor-not-allowed bg-gray-100": disabled, "bg-white": !disabled },
|
||||
"border border-base-content/20 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
|
||||
disabled ? "cursor-not-allowed bg-base-200" : "bg-base-100",
|
||||
)}
|
||||
value={internalValue}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInternalValue(e.target.value);
|
||||
setInputValue(e.target.value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -24,7 +24,7 @@ const options: Array<SelectOption> = [
|
||||
id: 3,
|
||||
label: "Text Only",
|
||||
disabled: false,
|
||||
value: "default&hide_progress=true&card_width=315",
|
||||
value: "hide_progress",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type DebouncedFieldType = "text" | "number";
|
||||
|
||||
/** Value type handled by the hook for a given input {@link DebouncedFieldType} */
|
||||
type DebouncedFieldValue<T extends DebouncedFieldType> = T extends "number"
|
||||
? number | undefined
|
||||
: string;
|
||||
|
||||
interface UseDebouncedFieldResult {
|
||||
/** Controlled value for the `<input>` */
|
||||
inputValue: string;
|
||||
/** Updates the local value; the parent is notified after the debounce */
|
||||
setInputValue: (input: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_DELAY_MS = 700;
|
||||
|
||||
/** Render any supported field value as the string shown in the `<input>` */
|
||||
const toInputString = (value: string | number | undefined): string =>
|
||||
value == null ? "" : String(value);
|
||||
|
||||
/** Parse an edited input string into a number (or `undefined` if empty/NaN) */
|
||||
const toNumber = (input: string): number | undefined => {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseInt(input, 10);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Local, debounced state for a controlled text input that mirrors an external value.
|
||||
* Keeps a string copy of `value`, resets that copy when `value` changes,
|
||||
* and notifies `onValueChange` once the user stops typing for `delay` milliseconds.
|
||||
* `type` decides how the edited string is parsed before it is emitted
|
||||
* (kept as a string for `"text"`, parsed to a number for `"number"`).
|
||||
*/
|
||||
export function useDebouncedField<T extends DebouncedFieldType>({
|
||||
value,
|
||||
onValueChange,
|
||||
type,
|
||||
delay = DEFAULT_DELAY_MS,
|
||||
}: {
|
||||
value: DebouncedFieldValue<T>;
|
||||
onValueChange: (value: DebouncedFieldValue<T>) => void;
|
||||
type: T;
|
||||
delay?: number;
|
||||
}): UseDebouncedFieldResult {
|
||||
const [inputValue, setInputValue] = useState(() => toInputString(value));
|
||||
const debounceTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// Reset the local value when the incoming prop changes by adjusting state
|
||||
// during render — React's recommended alternative to a syncing effect:
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
const [prevValue, setPrevValue] = useState(value);
|
||||
if (value !== prevValue) {
|
||||
setPrevValue(value);
|
||||
setInputValue(toInputString(value));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
window.clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
// Nothing to emit while the input still mirrors the source value
|
||||
if (inputValue === toInputString(value)) {
|
||||
return undefined;
|
||||
}
|
||||
debounceTimeoutRef.current = window.setTimeout(() => {
|
||||
const nextValue = type === "number" ? toNumber(inputValue) : inputValue;
|
||||
// `type` guarantees `nextValue` matches the caller's value variant
|
||||
onValueChange(nextValue as DebouncedFieldValue<T>);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(debounceTimeoutRef.current as number);
|
||||
};
|
||||
}, [inputValue, value, onValueChange, type, delay]);
|
||||
|
||||
return { inputValue, setInputValue };
|
||||
}
|
||||
+103
-2
@@ -1,14 +1,115 @@
|
||||
/* ./src/index.css */
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
light --default,
|
||||
dark --prefersdark;
|
||||
}
|
||||
|
||||
/*
|
||||
Override primary color with default blue from github-stats-extended.
|
||||
Override background color in dark mode with GitHub's dark mode background color.
|
||||
*/
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
--color-primary: #2f80ed;
|
||||
--color-error: var(--color-red-700);
|
||||
}
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dark";
|
||||
--color-primary: #2f80ed;
|
||||
--color-error: var(--color-red-900);
|
||||
--color-base-100: #0d1117;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Snappy duration for interactive elements (buttons, hovers). */
|
||||
--transition-fast: 100ms;
|
||||
/* Slower, deliberate fade for large surfaces during a theme switch. */
|
||||
--transition-surface: 300ms;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Ubuntu, Sans-Serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Smoothly animate color changes when switching themes / on hover. */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
/*
|
||||
* An ease-out curve: animations start at full speed so quick interactions
|
||||
* like button hovers feel responsive instead of laggy.
|
||||
*/
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
transition-duration: var(--transition-fast);
|
||||
}
|
||||
|
||||
/*
|
||||
* Large surface backgrounds fade more slowly so a theme switch reads as a
|
||||
* deliberate fade rather than an instant flip. Durations reference the custom
|
||||
* properties above so the reduced-motion override below zeroes them out
|
||||
* regardless of selector specificity.
|
||||
*/
|
||||
body,
|
||||
.bg-base-100,
|
||||
.bg-base-200,
|
||||
.bg-base-300 {
|
||||
transition-duration: var(--transition-surface);
|
||||
}
|
||||
|
||||
/*
|
||||
* DaisyUI's `.btn` ships its own 200ms transition which felt slow. The global
|
||||
* rule above is unlayered, so it would otherwise clobber the button's property
|
||||
* list and timing function. Restore both here (so fill, text and border animate
|
||||
* together on outline buttons) and only speed up the duration.
|
||||
*/
|
||||
.btn {
|
||||
transition-property: color, background-color, border-color, box-shadow;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
transition-duration: var(--transition-fast);
|
||||
}
|
||||
|
||||
/*
|
||||
* DaisyUI's default hover is very subtle; make it noticeable. Primary always darkens,
|
||||
* while soft lightens in dark mode and darkens in light mode, so the change shows.
|
||||
*/
|
||||
.btn-primary:hover {
|
||||
background-color: color-mix(in oklab, var(--color-primary), #000 30%);
|
||||
}
|
||||
.btn-soft {
|
||||
background-color: color-mix(in oklab, var(--color-base-100), #888 35%);
|
||||
}
|
||||
.btn-soft:hover {
|
||||
background-color: color-mix(in oklab, var(--color-base-100), #888 60%);
|
||||
}
|
||||
.btn-error {
|
||||
color: var(--color-neutral-content);
|
||||
}
|
||||
.btn-error:hover {
|
||||
color: var(--color-neutral-content);
|
||||
background-color: color-mix(in oklab, var(--color-error), #f00 40%);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 0;
|
||||
border: var(--color-primary) solid 1px;
|
||||
}
|
||||
|
||||
/* Respect users who prefer reduced motion. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-fast: 0ms;
|
||||
--transition-surface: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,75 +4,45 @@
|
||||
"repositories": {
|
||||
"totalCount": 139,
|
||||
"nodes": [
|
||||
{ "name": "classicLogo", "stargazers": { "totalCount": 1 } },
|
||||
{ "name": "creativechat", "stargazers": { "totalCount": 1 } },
|
||||
{ "name": "verlet_tests", "stargazers": { "totalCount": 1 } },
|
||||
{
|
||||
"name": "blade-checkout-ui-demo",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{
|
||||
"name": "tslint-plugin-analytics",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "stargazer", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "react-smooth-range-input",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{
|
||||
"name": "csb-git-integration-bug",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "covid-nepal-web", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "shrijan00003.github.io",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "TheBigCB.com", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "html2commonmark", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "test-myaction", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "react-emoji-render", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "sajilo-editor", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "coder-aayush.github.io",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "codesandbox-client", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "product-sans", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "NodejsBlogSystem", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "native-javascript", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "p5.js", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "gatsbytutorials.com", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "hello-world-docker-action",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "glugmvit.github.io", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "react-map-interaction",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "react-external-image", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "gatsby-boilerplate", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "YouBackup", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "npm-expansions", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "Toy-Neural-Network-JS",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "LSystemCreator", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "Happy-2019", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "jquery-navScroll", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "strml.net", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "Logo", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "website", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "anuraghazra.github.io-old",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "Quintus", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "Electron_experiments", "stargazers": { "totalCount": 0 } }
|
||||
{ "name": "classicLogo", "stargazerCount": 1 },
|
||||
{ "name": "creativechat", "stargazerCount": 1 },
|
||||
{ "name": "verlet_tests", "stargazerCount": 1 },
|
||||
{ "name": "blade-checkout-ui-demo", "stargazerCount": 0 },
|
||||
{ "name": "tslint-plugin-analytics", "stargazerCount": 0 },
|
||||
{ "name": "stargazer", "stargazerCount": 0 },
|
||||
{ "name": "react-smooth-range-input", "stargazerCount": 0 },
|
||||
{ "name": "csb-git-integration-bug", "stargazerCount": 0 },
|
||||
{ "name": "covid-nepal-web", "stargazerCount": 0 },
|
||||
{ "name": "shrijan00003.github.io", "stargazerCount": 0 },
|
||||
{ "name": "TheBigCB.com", "stargazerCount": 0 },
|
||||
{ "name": "html2commonmark", "stargazerCount": 0 },
|
||||
{ "name": "test-myaction", "stargazerCount": 0 },
|
||||
{ "name": "react-emoji-render", "stargazerCount": 0 },
|
||||
{ "name": "sajilo-editor", "stargazerCount": 0 },
|
||||
{ "name": "coder-aayush.github.io", "stargazerCount": 0 },
|
||||
{ "name": "codesandbox-client", "stargazerCount": 0 },
|
||||
{ "name": "product-sans", "stargazerCount": 0 },
|
||||
{ "name": "NodejsBlogSystem", "stargazerCount": 0 },
|
||||
{ "name": "native-javascript", "stargazerCount": 0 },
|
||||
{ "name": "p5.js", "stargazerCount": 0 },
|
||||
{ "name": "gatsbytutorials.com", "stargazerCount": 0 },
|
||||
{ "name": "hello-world-docker-action", "stargazerCount": 0 },
|
||||
{ "name": "glugmvit.github.io", "stargazerCount": 0 },
|
||||
{ "name": "react-map-interaction", "stargazerCount": 0 },
|
||||
{ "name": "react-external-image", "stargazerCount": 0 },
|
||||
{ "name": "gatsby-boilerplate", "stargazerCount": 0 },
|
||||
{ "name": "YouBackup", "stargazerCount": 0 },
|
||||
{ "name": "npm-expansions", "stargazerCount": 0 },
|
||||
{ "name": "Toy-Neural-Network-JS", "stargazerCount": 0 },
|
||||
{ "name": "LSystemCreator", "stargazerCount": 0 },
|
||||
{ "name": "Happy-2019", "stargazerCount": 0 },
|
||||
{ "name": "jquery-navScroll", "stargazerCount": 0 },
|
||||
{ "name": "strml.net", "stargazerCount": 0 },
|
||||
{ "name": "Logo", "stargazerCount": 0 },
|
||||
{ "name": "website", "stargazerCount": 0 },
|
||||
{ "name": "anuraghazra.github.io-old", "stargazerCount": 0 },
|
||||
{ "name": "Quintus", "stargazerCount": 0 },
|
||||
{ "name": "Electron_experiments", "stargazerCount": 0 }
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
"isPrivate": false,
|
||||
"isArchived": false,
|
||||
"isTemplate": false,
|
||||
"stargazers": {
|
||||
"totalCount": 76994
|
||||
},
|
||||
"stargazerCount": 76994,
|
||||
"description": ":zap: Dynamically generated stats for your github readmes",
|
||||
"primaryLanguage": {
|
||||
"color": "#f1e05a",
|
||||
|
||||
@@ -36,606 +36,109 @@
|
||||
"repositories": {
|
||||
"totalCount": 139,
|
||||
"nodes": [
|
||||
{
|
||||
"name": "github-readme-stats",
|
||||
"stargazers": {
|
||||
"totalCount": 77025
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Verly.js",
|
||||
"stargazers": {
|
||||
"totalCount": 696
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "anuraghazra.github.io",
|
||||
"stargazers": {
|
||||
"totalCount": 608
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "anuraghazra",
|
||||
"stargazers": {
|
||||
"totalCount": 376
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type-trident",
|
||||
"stargazers": {
|
||||
"totalCount": 359
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BugVilla",
|
||||
"stargazers": {
|
||||
"totalCount": 232
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "convoychat",
|
||||
"stargazers": {
|
||||
"totalCount": 214
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CanvasFun",
|
||||
"stargazers": {
|
||||
"totalCount": 211
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "EvolutionAquerium",
|
||||
"stargazers": {
|
||||
"totalCount": 192
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "typelevel-parser",
|
||||
"stargazers": {
|
||||
"totalCount": 147
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VerlyRangeSlider",
|
||||
"stargazers": {
|
||||
"totalCount": 135
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vscode-strip-ts-copy",
|
||||
"stargazers": {
|
||||
"totalCount": 72
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-folder-tree",
|
||||
"stargazers": {
|
||||
"totalCount": 54
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Atomic.js",
|
||||
"stargazers": {
|
||||
"totalCount": 53
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ToyLang",
|
||||
"stargazers": {
|
||||
"totalCount": 51
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "design-patterns-everyday",
|
||||
"stargazers": {
|
||||
"totalCount": 46
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby",
|
||||
"stargazers": {
|
||||
"totalCount": 36
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Slime",
|
||||
"stargazers": {
|
||||
"totalCount": 35
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Candy.js",
|
||||
"stargazers": {
|
||||
"totalCount": 34
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ShaderExpo",
|
||||
"stargazers": {
|
||||
"totalCount": 33
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Nothing",
|
||||
"stargazers": {
|
||||
"totalCount": 32
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "peerlist-profile-action",
|
||||
"stargazers": {
|
||||
"totalCount": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "facebook-reaction-animation",
|
||||
"stargazers": {
|
||||
"totalCount": 29
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-stripe-dropdown",
|
||||
"stargazers": {
|
||||
"totalCount": 27
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "QuickerPoll",
|
||||
"stargazers": {
|
||||
"totalCount": 27
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "playground-format-on-save",
|
||||
"stargazers": {
|
||||
"totalCount": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ParticleBrush",
|
||||
"stargazers": {
|
||||
"totalCount": 23
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graphql-oauth",
|
||||
"stargazers": {
|
||||
"totalCount": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "parasites",
|
||||
"stargazers": {
|
||||
"totalCount": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "twitter-banner-jokes",
|
||||
"stargazers": {
|
||||
"totalCount": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "verly-cpp",
|
||||
"stargazers": {
|
||||
"totalCount": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-github-issues-blog",
|
||||
"stargazers": {
|
||||
"totalCount": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "NikeHyperAce",
|
||||
"stargazers": {
|
||||
"totalCount": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rust-particles",
|
||||
"stargazers": {
|
||||
"totalCount": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VerletDrawing",
|
||||
"stargazers": {
|
||||
"totalCount": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graphql-jwt-auth-test",
|
||||
"stargazers": {
|
||||
"totalCount": 9
|
||||
}
|
||||
},
|
||||
{ "name": "github-readme-stats", "stargazerCount": 77025 },
|
||||
{ "name": "Verly.js", "stargazerCount": 696 },
|
||||
{ "name": "anuraghazra.github.io", "stargazerCount": 608 },
|
||||
{ "name": "anuraghazra", "stargazerCount": 376 },
|
||||
{ "name": "type-trident", "stargazerCount": 359 },
|
||||
{ "name": "BugVilla", "stargazerCount": 232 },
|
||||
{ "name": "convoychat", "stargazerCount": 214 },
|
||||
{ "name": "CanvasFun", "stargazerCount": 211 },
|
||||
{ "name": "EvolutionAquerium", "stargazerCount": 192 },
|
||||
{ "name": "typelevel-parser", "stargazerCount": 147 },
|
||||
{ "name": "VerlyRangeSlider", "stargazerCount": 135 },
|
||||
{ "name": "vscode-strip-ts-copy", "stargazerCount": 72 },
|
||||
{ "name": "react-folder-tree", "stargazerCount": 54 },
|
||||
{ "name": "Atomic.js", "stargazerCount": 53 },
|
||||
{ "name": "ToyLang", "stargazerCount": 51 },
|
||||
{ "name": "design-patterns-everyday", "stargazerCount": 46 },
|
||||
{ "name": "gatsby", "stargazerCount": 36 },
|
||||
{ "name": "Slime", "stargazerCount": 35 },
|
||||
{ "name": "Candy.js", "stargazerCount": 34 },
|
||||
{ "name": "ShaderExpo", "stargazerCount": 33 },
|
||||
{ "name": "Nothing", "stargazerCount": 32 },
|
||||
{ "name": "peerlist-profile-action", "stargazerCount": 30 },
|
||||
{ "name": "facebook-reaction-animation", "stargazerCount": 29 },
|
||||
{ "name": "react-stripe-dropdown", "stargazerCount": 27 },
|
||||
{ "name": "QuickerPoll", "stargazerCount": 27 },
|
||||
{ "name": "playground-format-on-save", "stargazerCount": 24 },
|
||||
{ "name": "ParticleBrush", "stargazerCount": 23 },
|
||||
{ "name": "graphql-oauth", "stargazerCount": 21 },
|
||||
{ "name": "parasites", "stargazerCount": 21 },
|
||||
{ "name": "twitter-banner-jokes", "stargazerCount": 18 },
|
||||
{ "name": "verly-cpp", "stargazerCount": 16 },
|
||||
{ "name": "gatsby-github-issues-blog", "stargazerCount": 16 },
|
||||
{ "name": "NikeHyperAce", "stargazerCount": 12 },
|
||||
{ "name": "rust-particles", "stargazerCount": 11 },
|
||||
{ "name": "VerletDrawing", "stargazerCount": 10 },
|
||||
{ "name": "graphql-jwt-auth-test", "stargazerCount": 9 },
|
||||
{
|
||||
"name": "anuraghazra.github.io-old-react",
|
||||
"stargazers": {
|
||||
"totalCount": 9
|
||||
}
|
||||
"stargazerCount": 9
|
||||
},
|
||||
{
|
||||
"name": "react18-ssr-test",
|
||||
"stargazers": {
|
||||
"totalCount": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GyroDodge",
|
||||
"stargazers": {
|
||||
"totalCount": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "go-phyllotaxis",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graphql-oauth-client",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code-runner-action",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GithubActionsPlayground",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Loader.js",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ts-essentials",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chakra-ui",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "text-to-handwriting",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "build-your-own-x",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nodejs-dynamic-restapi",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DevYouTubeList",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "FlockingBlackHole",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "webgpu-boids",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-githubblock",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "renderless-components",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reakit",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "micro-open-graph",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rest-and-graphql",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "abell-issues-blog",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VanillaMVC",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "covid19-sim",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "YoutubeDownloader",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "verlet.js",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-github-ts",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tw-dynamic-interpolation",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ts-string-interpolator",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "AnuReact",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CanvasBalls",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "circleci-test",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-starter-netlify-cms",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "WebGL.js",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-fork-workflow",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-codesandbox-projects",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "codesandbox-template-astro",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apollo",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "api-docs",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Buddies-movie",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2k76",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-remark-embedder",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "weird-hello-worlds",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rn-tabview-test-repro",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tailwindcss-jit",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DefinitelyTyped",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-components-demo",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volant-vo",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-spectrum",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "webhooks-test",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-showdown",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transcode",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "awake-yet-action",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bradgarropy.com",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-plugin-social-banners",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tour360",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blizard.js",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keep-a-changelog",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vulnerabilities",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "harleydavidson",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "awesome-webgl",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "NewtonsInterpolation",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "now-github-starter",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "simplerockets",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
}
|
||||
{ "name": "react18-ssr-test", "stargazerCount": 8 },
|
||||
{ "name": "GyroDodge", "stargazerCount": 8 },
|
||||
{ "name": "go-phyllotaxis", "stargazerCount": 6 },
|
||||
{ "name": "graphql-oauth-client", "stargazerCount": 6 },
|
||||
{ "name": "code-runner-action", "stargazerCount": 6 },
|
||||
{ "name": "GithubActionsPlayground", "stargazerCount": 6 },
|
||||
{ "name": "Loader.js", "stargazerCount": 6 },
|
||||
{ "name": "ts-essentials", "stargazerCount": 5 },
|
||||
{ "name": "chakra-ui", "stargazerCount": 5 },
|
||||
{ "name": "text-to-handwriting", "stargazerCount": 5 },
|
||||
{ "name": "build-your-own-x", "stargazerCount": 5 },
|
||||
{ "name": "nodejs-dynamic-restapi", "stargazerCount": 5 },
|
||||
{ "name": "DevYouTubeList", "stargazerCount": 5 },
|
||||
{ "name": "FlockingBlackHole", "stargazerCount": 5 },
|
||||
{ "name": "webgpu-boids", "stargazerCount": 4 },
|
||||
{ "name": "test-githubblock", "stargazerCount": 4 },
|
||||
{ "name": "renderless-components", "stargazerCount": 4 },
|
||||
{ "name": "reakit", "stargazerCount": 4 },
|
||||
{ "name": "micro-open-graph", "stargazerCount": 4 },
|
||||
{ "name": "rest-and-graphql", "stargazerCount": 4 },
|
||||
{ "name": "abell-issues-blog", "stargazerCount": 4 },
|
||||
{ "name": "VanillaMVC", "stargazerCount": 4 },
|
||||
{ "name": "covid19-sim", "stargazerCount": 4 },
|
||||
{ "name": "YoutubeDownloader", "stargazerCount": 4 },
|
||||
{ "name": "verlet.js", "stargazerCount": 4 },
|
||||
{ "name": "test-github-ts", "stargazerCount": 3 },
|
||||
{ "name": "tw-dynamic-interpolation", "stargazerCount": 3 },
|
||||
{ "name": "ts-string-interpolator", "stargazerCount": 3 },
|
||||
{ "name": "AnuReact", "stargazerCount": 3 },
|
||||
{ "name": "CanvasBalls", "stargazerCount": 3 },
|
||||
{ "name": "circleci-test", "stargazerCount": 3 },
|
||||
{ "name": "gatsby-starter-netlify-cms", "stargazerCount": 3 },
|
||||
{ "name": "WebGL.js", "stargazerCount": 3 },
|
||||
{ "name": "test-fork-workflow", "stargazerCount": 2 },
|
||||
{ "name": "test-codesandbox-projects", "stargazerCount": 2 },
|
||||
{ "name": "codesandbox-template-astro", "stargazerCount": 2 },
|
||||
{ "name": "apollo", "stargazerCount": 2 },
|
||||
{ "name": "api-docs", "stargazerCount": 2 },
|
||||
{ "name": "Buddies-movie", "stargazerCount": 2 },
|
||||
{ "name": "2k76", "stargazerCount": 2 },
|
||||
{ "name": "gatsby-remark-embedder", "stargazerCount": 2 },
|
||||
{ "name": "weird-hello-worlds", "stargazerCount": 2 },
|
||||
{ "name": "rn-tabview-test-repro", "stargazerCount": 1 },
|
||||
{ "name": "tailwindcss-jit", "stargazerCount": 1 },
|
||||
{ "name": "DefinitelyTyped", "stargazerCount": 1 },
|
||||
{ "name": "server-components-demo", "stargazerCount": 1 },
|
||||
{ "name": "volant-vo", "stargazerCount": 1 },
|
||||
{ "name": "react-spectrum", "stargazerCount": 1 },
|
||||
{ "name": "webhooks-test", "stargazerCount": 1 },
|
||||
{ "name": "react-showdown", "stargazerCount": 1 },
|
||||
{ "name": "transcode", "stargazerCount": 1 },
|
||||
{ "name": "awake-yet-action", "stargazerCount": 1 },
|
||||
{ "name": "bradgarropy.com", "stargazerCount": 1 },
|
||||
{ "name": "gatsby-plugin-social-banners", "stargazerCount": 1 },
|
||||
{ "name": "Tour360", "stargazerCount": 1 },
|
||||
{ "name": "blizard.js", "stargazerCount": 1 },
|
||||
{ "name": "keep-a-changelog", "stargazerCount": 1 },
|
||||
{ "name": "vulnerabilities", "stargazerCount": 1 },
|
||||
{ "name": "harleydavidson", "stargazerCount": 1 },
|
||||
{ "name": "awesome-webgl", "stargazerCount": 1 },
|
||||
{ "name": "NewtonsInterpolation", "stargazerCount": 1 },
|
||||
{ "name": "now-github-starter", "stargazerCount": 1 },
|
||||
{ "name": "simplerockets", "stargazerCount": 1 }
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
|
||||
@@ -6,3 +6,30 @@ export const CardType = {
|
||||
WAKATIME: "wakatime",
|
||||
} as const;
|
||||
export type CardType = (typeof CardType)[keyof typeof CardType];
|
||||
|
||||
export const CardCategory = {
|
||||
/**
|
||||
* Cards describing a single repo or gist.
|
||||
* They accept `show_owner` and use the `_repocard` theme variants.
|
||||
*/
|
||||
REPO: "repo",
|
||||
/**
|
||||
* Cards describing a user: stats, top languages and WakaTime.
|
||||
*/
|
||||
USER: "user",
|
||||
} as const;
|
||||
export type CardCategory = (typeof CardCategory)[keyof typeof CardCategory];
|
||||
|
||||
/**
|
||||
* Category each card belongs to.
|
||||
*
|
||||
* Exhaustive by construction: a new {@link CardType} will not compile until it
|
||||
* is categorised here, rather than silently falling into one of the branches.
|
||||
*/
|
||||
export const CATEGORY_BY_CARD_TYPE: Record<CardType, CardCategory> = {
|
||||
[CardType.STATS]: CardCategory.USER,
|
||||
[CardType.TOP_LANGS]: CardCategory.USER,
|
||||
[CardType.WAKATIME]: CardCategory.USER,
|
||||
[CardType.PIN]: CardCategory.REPO,
|
||||
[CardType.GIST]: CardCategory.REPO,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CardType } from "./CardType";
|
||||
import { cardUrl } from "./CardUrl";
|
||||
|
||||
describe("cardUrl", () => {
|
||||
it("maps each card type to its path", () => {
|
||||
expect(cardUrl(CardType.STATS).toString()).toBe("");
|
||||
expect(cardUrl(CardType.TOP_LANGS).toString()).toBe("/top-langs");
|
||||
expect(cardUrl(CardType.PIN).toString()).toBe("/pin");
|
||||
expect(cardUrl(CardType.GIST).toString()).toBe("/gist");
|
||||
expect(cardUrl(CardType.WAKATIME).toString()).toBe("/wakatime");
|
||||
});
|
||||
|
||||
it("builds a suffix with params", () => {
|
||||
expect(
|
||||
cardUrl(CardType.TOP_LANGS).username("john").langsCount(4).toString(),
|
||||
).toBe("/top-langs?username=john&langs_count=4");
|
||||
});
|
||||
|
||||
it("derives a download filename per card", () => {
|
||||
expect(cardUrl(CardType.STATS).username("john").filename()).toBe(
|
||||
"john_card",
|
||||
);
|
||||
expect(cardUrl(CardType.TOP_LANGS).username("john").filename()).toBe(
|
||||
"john_card",
|
||||
);
|
||||
expect(cardUrl(CardType.PIN).repo("owner/repo1").filename()).toBe(
|
||||
"owner/repo1_card",
|
||||
);
|
||||
expect(cardUrl(CardType.GIST).gistId("abc").filename()).toBe("gist_card");
|
||||
expect(cardUrl(CardType.WAKATIME).username("waka").filename()).toBe(
|
||||
"waka_card",
|
||||
);
|
||||
});
|
||||
|
||||
it("is immutable: setters return new instances", () => {
|
||||
const base = cardUrl(CardType.STATS).username("john");
|
||||
const withIcons = base.showIcons();
|
||||
|
||||
expect(base.toString()).toBe("?username=john");
|
||||
expect(withIcons.toString()).toBe("?username=john&show_icons=true");
|
||||
});
|
||||
|
||||
it("sets values verbatim (callers decide whether to include a param)", () => {
|
||||
// no dropping of empty/false — the value is set as given
|
||||
expect(cardUrl(CardType.STATS).username("").toString()).toBe("?username=");
|
||||
});
|
||||
|
||||
it("encodes special characters", () => {
|
||||
// spaces as %20 (not +), commas as %2C
|
||||
expect(
|
||||
cardUrl(CardType.STATS)
|
||||
.username("john")
|
||||
.customTitle("My Stats")
|
||||
.toString(),
|
||||
).toBe("?username=john&custom_title=My%20Stats");
|
||||
expect(
|
||||
cardUrl(CardType.STATS).username("john").show("a,b,c").toString(),
|
||||
).toBe("?username=john&show=a%2Cb%2Cc");
|
||||
});
|
||||
|
||||
it("builds an absolute api url with the given host", () => {
|
||||
expect(
|
||||
cardUrl(CardType.STATS)
|
||||
.username("john")
|
||||
.client("wizard")
|
||||
.toApiUrl("example.com"),
|
||||
).toBe("https://example.com/api?username=john&client=wizard");
|
||||
});
|
||||
|
||||
it("applies a theme via the universal setter", () => {
|
||||
expect(
|
||||
cardUrl(CardType.STATS).username("john").theme("github_dark").toString(),
|
||||
).toBe("?username=john&theme=github_dark");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { CardType } from "./CardType";
|
||||
|
||||
/**
|
||||
* Immutable, per-card builders for the query-string suffix of a card URL.
|
||||
*
|
||||
* Each card type accepts a different set of query params, so there is one
|
||||
* builder class per card exposing only the params that card supports. Setters
|
||||
* return a new instance (immutable); {@link CardUrlBase.toString} produces the
|
||||
* relative suffix (e.g. `/top-langs?username=foo&langs_count=4`).
|
||||
*
|
||||
* Which card accepts which param:
|
||||
*
|
||||
* | param | STATS | TOP_LANGS | PIN | GIST | WAKATIME |
|
||||
* | ----------------------- | :---: | :-------: | :-: | :--: | :------: |
|
||||
* | username | ✓ | ✓ | ✓ | | ✓ |
|
||||
* | hide_title | ✓ | ✓ | | | ✓ |
|
||||
* | custom_title | ✓ | | | | ✓ |
|
||||
* | rank_icon | ✓ | | | | |
|
||||
* | hide_rank | ✓ | | | | |
|
||||
* | show | ✓ | | | | |
|
||||
* | show_icons | ✓ | | | | |
|
||||
* | include_all_commits | ✓ | | | | |
|
||||
* | hide_values | | ✓ | | | |
|
||||
* | layout | | ✓ | | | ✓ |
|
||||
* | hide_progress | | ✓ | | | ✓ |
|
||||
* | langs_count | | ✓ | | | ✓ |
|
||||
* | repo | | | ✓ | | |
|
||||
* | description_lines_count | | | ✓ | | |
|
||||
* | show_owner | | | ✓ | ✓ | |
|
||||
* | id (gist) | | | | ✓ | |
|
||||
* | display_format | | | | | ✓ |
|
||||
* | card_width | | | | | ✓ |
|
||||
* | theme | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
* | client | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
* | disable_animations | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
*/
|
||||
abstract class CardUrlBase<S extends CardUrlBase<S>> {
|
||||
protected constructor(
|
||||
protected readonly params: ReadonlyMap<string, string>,
|
||||
) {}
|
||||
|
||||
/** Build a new instance of the concrete card builder. */
|
||||
protected abstract create(params: ReadonlyMap<string, string>): S;
|
||||
|
||||
/** Path segment appended after `/api` (empty for the stats card). */
|
||||
protected abstract readonly path: string;
|
||||
|
||||
/**
|
||||
* The single place that produces a new instance. Sets the param verbatim;
|
||||
* callers decide whether a param should be included (see `buildCardUrl`).
|
||||
*/
|
||||
protected with(key: string, value: string | number | boolean): S {
|
||||
const next = new Map(this.params);
|
||||
next.set(key, String(value));
|
||||
return this.create(next);
|
||||
}
|
||||
|
||||
// ---- universal setters (valid on every card) ----
|
||||
theme(v: string): S {
|
||||
return this.with("theme", v);
|
||||
}
|
||||
client(v: string): S {
|
||||
return this.with("client", v);
|
||||
}
|
||||
disableAnimations(v = true): S {
|
||||
return this.with("disable_animations", v);
|
||||
}
|
||||
|
||||
/** Suggested download filename (without extension) for this card. */
|
||||
abstract filename(): string;
|
||||
|
||||
/** Relative suffix, e.g. `/top-langs?username=foo&langs_count=4`. */
|
||||
toString(): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [k, v] of this.params) {
|
||||
search.set(k, v);
|
||||
}
|
||||
// URLSearchParams encodes spaces as `+`; normalize to `%20` for parity with
|
||||
// encodeURIComponent and universal query parsing.
|
||||
const qs = search.toString().replace(/\+/g, "%20");
|
||||
return `${this.path}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL the `<img>`/SvgInline loads. `host` is passed in so this
|
||||
* module stays free of the `window`-dependent constants.
|
||||
*/
|
||||
toApiUrl(host: string): string {
|
||||
return `https://${host}/api${this.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
class StatsCardUrl extends CardUrlBase<StatsCardUrl> {
|
||||
protected readonly path = "";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new StatsCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new StatsCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("username") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
rankIcon(v: string) {
|
||||
return this.with("rank_icon", v);
|
||||
}
|
||||
hideRank(v = true) {
|
||||
return this.with("hide_rank", v);
|
||||
}
|
||||
hideTitle(v = true) {
|
||||
return this.with("hide_title", v);
|
||||
}
|
||||
customTitle(v: string) {
|
||||
return this.with("custom_title", v);
|
||||
}
|
||||
show(v: string) {
|
||||
return this.with("show", v);
|
||||
}
|
||||
showIcons(v = true) {
|
||||
return this.with("show_icons", v);
|
||||
}
|
||||
includeAllCommits(v = true) {
|
||||
return this.with("include_all_commits", v);
|
||||
}
|
||||
}
|
||||
|
||||
class TopLangsCardUrl extends CardUrlBase<TopLangsCardUrl> {
|
||||
protected readonly path = "/top-langs";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new TopLangsCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new TopLangsCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("username") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
layout(v: string) {
|
||||
return this.with("layout", v);
|
||||
}
|
||||
hideProgress(v = true) {
|
||||
return this.with("hide_progress", v);
|
||||
}
|
||||
hideTitle(v = true) {
|
||||
return this.with("hide_title", v);
|
||||
}
|
||||
langsCount(v: number) {
|
||||
return this.with("langs_count", v);
|
||||
}
|
||||
hideValues(v = true) {
|
||||
return this.with("hide_values", v);
|
||||
}
|
||||
}
|
||||
|
||||
class PinCardUrl extends CardUrlBase<PinCardUrl> {
|
||||
protected readonly path = "/pin";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new PinCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new PinCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("repo") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
repo(v: string) {
|
||||
return this.with("repo", v);
|
||||
}
|
||||
showOwner(v = true) {
|
||||
return this.with("show_owner", v);
|
||||
}
|
||||
descriptionLines(v: number) {
|
||||
return this.with("description_lines_count", v);
|
||||
}
|
||||
}
|
||||
|
||||
class GistCardUrl extends CardUrlBase<GistCardUrl> {
|
||||
protected readonly path = "/gist";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new GistCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new GistCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return "gist_card";
|
||||
}
|
||||
|
||||
gistId(v: string) {
|
||||
return this.with("id", v);
|
||||
}
|
||||
showOwner(v = true) {
|
||||
return this.with("show_owner", v);
|
||||
}
|
||||
}
|
||||
|
||||
class WakatimeCardUrl extends CardUrlBase<WakatimeCardUrl> {
|
||||
protected readonly path = "/wakatime";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new WakatimeCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new WakatimeCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("username") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
layout(v: string) {
|
||||
return this.with("layout", v);
|
||||
}
|
||||
hideProgress(v = true) {
|
||||
return this.with("hide_progress", v);
|
||||
}
|
||||
hideTitle(v = true) {
|
||||
return this.with("hide_title", v);
|
||||
}
|
||||
customTitle(v: string) {
|
||||
return this.with("custom_title", v);
|
||||
}
|
||||
langsCount(v: number) {
|
||||
return this.with("langs_count", v);
|
||||
}
|
||||
displayFormat(v: string) {
|
||||
return this.with("display_format", v);
|
||||
}
|
||||
cardWidth(v: number) {
|
||||
return this.with("card_width", v);
|
||||
}
|
||||
}
|
||||
|
||||
/** Any card-URL builder, regardless of card type (all share the universal API). */
|
||||
export type CardUrlBuilder =
|
||||
StatsCardUrl | TopLangsCardUrl | PinCardUrl | GistCardUrl | WakatimeCardUrl;
|
||||
|
||||
/**
|
||||
* Create a card-URL builder typed to the given card, so only the params that
|
||||
* card supports are in scope (e.g. `cardUrl(CardType.GIST)` has no
|
||||
* `langsCount`).
|
||||
*/
|
||||
export function cardUrl(c: typeof CardType.STATS): StatsCardUrl;
|
||||
export function cardUrl(c: typeof CardType.TOP_LANGS): TopLangsCardUrl;
|
||||
export function cardUrl(c: typeof CardType.PIN): PinCardUrl;
|
||||
export function cardUrl(c: typeof CardType.GIST): GistCardUrl;
|
||||
export function cardUrl(c: typeof CardType.WAKATIME): WakatimeCardUrl;
|
||||
export function cardUrl(c: CardType) {
|
||||
switch (c) {
|
||||
case CardType.STATS:
|
||||
return StatsCardUrl.create();
|
||||
case CardType.TOP_LANGS:
|
||||
return TopLangsCardUrl.create();
|
||||
case CardType.PIN:
|
||||
return PinCardUrl.create();
|
||||
case CardType.GIST:
|
||||
return GistCardUrl.create();
|
||||
case CardType.WAKATIME:
|
||||
return WakatimeCardUrl.create();
|
||||
default:
|
||||
c satisfies never;
|
||||
throw new Error(`unknown card type: ${c as string}`);
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,34 @@ export const STAGE_LABELS = [
|
||||
{
|
||||
title: "Login",
|
||||
shortTitle: "Login",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
title: "Select a Card",
|
||||
shortTitle: "Select Card",
|
||||
description: "You will be able to customize your card in future steps.",
|
||||
},
|
||||
{
|
||||
title: "Modify Card Parameters",
|
||||
shortTitle: "Modify Parameters",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
title: "Choose a Theme",
|
||||
shortTitle: "Select Theme",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
title: "Display your Card",
|
||||
shortTitle: "Display Card",
|
||||
description:
|
||||
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
|
||||
},
|
||||
] as const satisfies Array<{ title: string; shortTitle: string }>;
|
||||
] as const satisfies Array<{
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
export type StageIndex = {
|
||||
[K in keyof typeof STAGE_LABELS]: K extends `${infer N extends number}`
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { ToastContainer, toast } from "react-toastify";
|
||||
|
||||
import { getUserMetadata } from "../../api/user";
|
||||
import { clearAxiosCache } from "../../axios-override";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
useUserKey,
|
||||
@@ -56,6 +57,7 @@ export function AppTrends() {
|
||||
const userKey = useUserKey();
|
||||
const userToken = useUserToken();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
|
||||
|
||||
@@ -79,24 +81,20 @@ export function AppTrends() {
|
||||
clearAxiosCache();
|
||||
}, [userToken]);
|
||||
|
||||
/*
|
||||
* Advance to step 1 only when `isAuthenticated` transitions, so a logged-in user can still navigate back to step 0.
|
||||
* Tracked during render (comparing the previous value) rather than in an effect:
|
||||
* https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
*/
|
||||
{
|
||||
/*
|
||||
* This effect must only be executed when `isAuthenticated` changes,
|
||||
* otherwise logged-in user are unable to go back to step 1.
|
||||
*/
|
||||
const prevIsAuthenticated = useRef(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevIsAuthenticated.current === isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevIsAuthenticated.current = isAuthenticated;
|
||||
|
||||
const [prevIsAuthenticated, setPrevIsAuthenticated] =
|
||||
useState(isAuthenticated);
|
||||
if (isAuthenticated !== prevIsAuthenticated) {
|
||||
setPrevIsAuthenticated(isAuthenticated);
|
||||
if (isAuthenticated && stage === 0) {
|
||||
setStage(1);
|
||||
}
|
||||
}, [isAuthenticated, stage]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,9 +114,9 @@ export function AppTrends() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header currStageIndex={stage} onStageIndexChange={setStage} />
|
||||
<section className="bg-white text-gray-700 flex-grow">
|
||||
<section className="bg-base-100 text-base-content flex-grow">
|
||||
<HomeScreen stage={stage} setStage={setStage} />
|
||||
<ToastContainer />
|
||||
<ToastContainer theme={isDark ? "dark" : "light"} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JSX } from "react";
|
||||
import { FaGithub as GithubIcon } from "react-icons/fa";
|
||||
|
||||
import appIcon from "../../assets/appLogo64.png";
|
||||
import { ThemePicker } from "../../components/Generic/ThemePicker";
|
||||
import { ProgressBar } from "../../components/Home/Progress";
|
||||
import { STAGE_LABELS } from "../../models/Stage";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
@@ -19,31 +20,34 @@ export function Header({
|
||||
}: HeaderProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="text-gray-100 bg-gray-800 shadow-md z-50">
|
||||
<div
|
||||
className="text-neutral-content shadow-md z-50"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in oklab, var(--color-primary), #000 50%)",
|
||||
}}
|
||||
>
|
||||
<div className="px-5 py-2 flex flex-wrap">
|
||||
{/* Logo */}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center title-font font-medium text-gray-50 mb-0 md:mr-8"
|
||||
className="flex items-center title-font font-medium text-neutral-content mb-0 md:mr-8"
|
||||
>
|
||||
<img src={appIcon} alt="logo" className="w-6 h-6" />
|
||||
<span className="ml-2 text-xl">GitHub Stats Extended</span>
|
||||
</a>
|
||||
{/* Star on GitHub */}
|
||||
<div className="flex ml-auto items-center text-base justify-center">
|
||||
{/* Star on GitHub + theme toggle */}
|
||||
<div className="flex ml-auto items-center gap-2 text-base justify-center">
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-[0.25rem] shadow bg-neutral-content hover:opacity-90 text-neutral px-3 py-1 flex items-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[0.25rem] shadow bg-gray-200 hover:bg-gray-300 text-black px-3 py-1 flex items-center"
|
||||
>
|
||||
Star on
|
||||
<GithubIcon className="ml-1.5 w-5 h-5" />
|
||||
</button>
|
||||
Star on
|
||||
<GithubIcon className="ml-1.5 w-5 h-5" />
|
||||
</a>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ThemeName } from "@stats-organization/github-readme-stats-core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { BounceLoader } from "react-spinners";
|
||||
@@ -7,18 +7,16 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { authenticate } from "../../api/user";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { DEMO_USER } from "../../constants";
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
HOST,
|
||||
} from "../../constants";
|
||||
import { CardType } from "../../models/CardType";
|
||||
CATEGORY_BY_CARD_TYPE,
|
||||
CardCategory,
|
||||
CardType,
|
||||
} from "../../models/CardType";
|
||||
import { STAGE_LABELS } from "../../models/Stage";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
usePrivateAccess,
|
||||
@@ -26,18 +24,28 @@ import {
|
||||
} from "../../redux/selectors/userSelectors";
|
||||
import { login } from "../../redux/slices/user";
|
||||
|
||||
import { getFullSuffix } from "./getFullSuffix";
|
||||
import { buildCardUrl } from "./buildCardUrl";
|
||||
import { getDefaultCardOptions } from "./cardOptions";
|
||||
import type { CardOptions } from "./cardOptions";
|
||||
import { CustomizeStage } from "./stages/Customize";
|
||||
import { DisplayStage } from "./stages/Display";
|
||||
import { LoginStage } from "./stages/Login/Login";
|
||||
import { SelectCardStage } from "./stages/SelectCard";
|
||||
import { ThemeStage } from "./stages/Theme";
|
||||
import { useCardDescriptor } from "./useCardDescriptor";
|
||||
|
||||
interface HomeScreenProps {
|
||||
stage: StageIndex;
|
||||
setStage: (stageIndex: StageIndex) => void;
|
||||
}
|
||||
|
||||
function getDefaultTheme(isDark: boolean, category: CardCategory): ThemeName {
|
||||
if (category === CardCategory.REPO) {
|
||||
return isDark ? "dark_github_repocard" : "light_github_repocard";
|
||||
}
|
||||
return isDark ? "dark_github" : "light_github";
|
||||
}
|
||||
|
||||
export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -47,59 +55,51 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// for stage two
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId);
|
||||
const [repo, setRepo] = useState(DEMO_REPO);
|
||||
const [gist, setGist] = useState(DEMO_GIST);
|
||||
const [wakatimeUser, setWakatimeUser] = useState(DEMO_WAKATIME_USER);
|
||||
|
||||
const [selectedCard, setSelectedCard] = useState<CardType>(CardType.STATS);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedUserId(userId);
|
||||
}, [userId]);
|
||||
|
||||
// for stage three
|
||||
const [selectedStatsRank, setSelectedStatsRank] =
|
||||
useState(STATS_DEFAULT_RANK);
|
||||
const [selectedLanguagesLayout, setSelectedLanguagesLayout] = useState(
|
||||
LANGUAGES_DEFAULT_LAYOUT,
|
||||
);
|
||||
const [selectedWakatimeLayout, setSelectedWakatimeLayout] = useState(
|
||||
WAKATIME_DEFAULT_LAYOUT,
|
||||
// for stages two and three
|
||||
const [cardOptions, setCardOptions] = useState(() =>
|
||||
getDefaultCardOptions(userId),
|
||||
);
|
||||
|
||||
const [showTitle, setShowTitle] = useState(true);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [descriptionLines, setDescriptionLines] = useState<
|
||||
number | undefined
|
||||
>();
|
||||
const [customTitle, setCustomTitle] = useState("");
|
||||
const [langsCount, setLangsCount] = useState<number | undefined>();
|
||||
const [hideValues, setHideValues] = useState(false);
|
||||
const [showAllStats, setShowAllStats] = useState(false);
|
||||
const [showIcons, setShowIcons] = useState(false);
|
||||
const [includeAllCommits, setIncludeAllCommits] = useState(true);
|
||||
const [enableAnimations, setEnableAnimations] = useState(true);
|
||||
const [usePercent, setUsePercent] = useState(false);
|
||||
const setCardOption = useCallback<
|
||||
<K extends keyof CardOptions>(key: K, value: CardOptions[K]) => void
|
||||
>((key, value) => {
|
||||
setCardOptions((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const [theme, setTheme] = useState("default");
|
||||
// Reset the selected user to the resolved account id when it changes,
|
||||
// adjusting state during render rather than in an effect:
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
const [prevUserId, setPrevUserId] = useState(userId);
|
||||
if (userId !== prevUserId) {
|
||||
setPrevUserId(userId);
|
||||
setCardOptions((prev) => ({ ...prev, selectedUserId: userId }));
|
||||
}
|
||||
|
||||
const { isDark } = useTheme();
|
||||
const cardCategory = CATEGORY_BY_CARD_TYPE[selectedCard];
|
||||
const [theme, setTheme] = useState(() =>
|
||||
getDefaultTheme(isDark, cardCategory),
|
||||
);
|
||||
|
||||
const handleCardTypeChange = (cardType: CardType) => {
|
||||
if (cardType === CardType.TOP_LANGS) {
|
||||
setLangsCount(4);
|
||||
setSelectedWakatimeLayout(WAKATIME_DEFAULT_LAYOUT);
|
||||
setCardOptions((prev) => ({
|
||||
...prev,
|
||||
langsCount: 4,
|
||||
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
|
||||
}));
|
||||
} else if (cardType === CardType.WAKATIME) {
|
||||
setLangsCount(6);
|
||||
setSelectedLanguagesLayout(LANGUAGES_DEFAULT_LAYOUT);
|
||||
setCardOptions((prev) => ({
|
||||
...prev,
|
||||
langsCount: 6,
|
||||
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
|
||||
}));
|
||||
}
|
||||
|
||||
if (theme === "default" || theme === "default_repocard") {
|
||||
if (cardType === CardType.PIN || cardType === CardType.GIST) {
|
||||
setTheme("default_repocard");
|
||||
} else {
|
||||
setTheme("default");
|
||||
}
|
||||
if (theme === getDefaultTheme(isDark, cardCategory)) {
|
||||
setTheme(getDefaultTheme(isDark, CATEGORY_BY_CARD_TYPE[cardType]));
|
||||
}
|
||||
|
||||
setSelectedCard(cardType);
|
||||
@@ -107,77 +107,36 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
setStage(2);
|
||||
};
|
||||
|
||||
const fullSuffix = getFullSuffix({
|
||||
userId,
|
||||
selectedCard,
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
});
|
||||
// Memoized so the builder keeps a stable reference across renders when its
|
||||
// inputs are unchanged. Card components consume it by value (they derive a URL
|
||||
// string), but memoizing keeps it safe to pass as a prop if any of them are
|
||||
// ever wrapped in React.memo.
|
||||
const cardBuilder = useMemo(
|
||||
() => buildCardUrl(userId, selectedCard, cardOptions),
|
||||
[userId, selectedCard, cardOptions],
|
||||
);
|
||||
|
||||
// Preview builder for the customize stage, dark-themed to match the surroundings.
|
||||
const customizeCardBuilder = useMemo(
|
||||
() => cardBuilder.theme(getDefaultTheme(isDark, cardCategory)),
|
||||
[cardBuilder, isDark, cardCategory],
|
||||
);
|
||||
|
||||
// for stage four
|
||||
let themeSuffix = fullSuffix;
|
||||
|
||||
if (
|
||||
!(
|
||||
(theme === "default" &&
|
||||
[CardType.STATS, CardType.TOP_LANGS, CardType.WAKATIME].includes(
|
||||
selectedCard as never,
|
||||
)) ||
|
||||
(theme === "default_repocard" &&
|
||||
[CardType.PIN, CardType.GIST].includes(selectedCard as never))
|
||||
)
|
||||
) {
|
||||
themeSuffix += `&theme=${theme}`;
|
||||
}
|
||||
const themeBuilder = useMemo(
|
||||
() => cardBuilder.theme(theme),
|
||||
[cardBuilder, theme],
|
||||
);
|
||||
|
||||
// for stage five
|
||||
const [gistUrl, setGistUrl] = useState("");
|
||||
|
||||
const guestHint = useMemo(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `username "${DEMO_USER}"`;
|
||||
case CardType.PIN:
|
||||
return `repo "${DEMO_REPO}"`;
|
||||
case CardType.GIST:
|
||||
return `Gist ID "${DEMO_GIST}"`;
|
||||
case CardType.WAKATIME:
|
||||
return `WakaTime username "${DEMO_WAKATIME_USER}"`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
}, [selectedCard]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchGistURL() {
|
||||
try {
|
||||
const fullUrl = `https://api.github.com/gists/${gist}`;
|
||||
const result = await axios.get<{ html_url: string }>(fullUrl);
|
||||
setGistUrl(result.data.html_url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
void fetchGistURL();
|
||||
}, [gist]);
|
||||
const cardDescriptor = useCardDescriptor({
|
||||
selectedCard,
|
||||
themeBuilder,
|
||||
repo: cardOptions.repo,
|
||||
userId,
|
||||
wakatimeUser: cardOptions.wakatimeUser,
|
||||
gist: cardOptions.gist,
|
||||
});
|
||||
|
||||
const contentSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -194,31 +153,31 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
}, [stage]);
|
||||
|
||||
useEffect(() => {
|
||||
async function redirectCode() {
|
||||
// After requesting GitHub access, GitHub redirects back to your app with a code parameter
|
||||
const url = window.location.href;
|
||||
// After requesting GitHub access, GitHub redirects back to the app with a
|
||||
// code parameter appended to the redirect URI (which carries mode=private|public).
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get("code");
|
||||
if (code === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If GitHub API returns the code parameter
|
||||
if (url.includes("code=")) {
|
||||
const tempPrivateAccess = url.includes("private");
|
||||
const newUrl = url.split("code=", 2) as [string, string];
|
||||
const redirect = `${url.split(HOST)[0] as string}${HOST}/frontend`;
|
||||
window.history.pushState({}, "", redirect);
|
||||
setIsLoading(true);
|
||||
const tempPrivateAccess = url.searchParams.get("mode") === "private";
|
||||
window.history.pushState({}, "", `${url.origin}/frontend`);
|
||||
|
||||
async function exchangeCode(code: string) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const userKey = uuidv4();
|
||||
const newUserId = await authenticate(
|
||||
newUrl[1],
|
||||
tempPrivateAccess,
|
||||
userKey,
|
||||
);
|
||||
|
||||
const newUserId = await authenticate(code, tempPrivateAccess, userKey);
|
||||
dispatch(login({ userId: newUserId, userKey }));
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void redirectCode();
|
||||
void exchangeCode(code);
|
||||
}, [dispatch]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -230,11 +189,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={contentSectionRef} className="h-full px-2 lg:px-8 text-gray-600">
|
||||
<div
|
||||
ref={contentSectionRef}
|
||||
className="h-full px-2 lg:px-8 text-base-content/70"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="m-4 rounded-sm">
|
||||
<div className="lg:p-4">
|
||||
<h1 className="text-2xl text-gray-600 font-semibold">
|
||||
<h1 className="text-2xl text-base-content/70 font-semibold">
|
||||
{STAGE_LABELS[stage].title}
|
||||
</h1>
|
||||
<div>
|
||||
@@ -245,7 +207,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
<a
|
||||
href={`https://github.com/${userId}`}
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:underline font-semibold"
|
||||
className="text-primary hover:underline font-semibold"
|
||||
>
|
||||
{userId}
|
||||
</a>
|
||||
@@ -266,13 +228,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
[
|
||||
"",
|
||||
"You will be able to customize your card in future steps.",
|
||||
"",
|
||||
"",
|
||||
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
|
||||
][stage]
|
||||
STAGE_LABELS[stage].description
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,50 +248,17 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
{stage === 2 && (
|
||||
<CustomizeStage
|
||||
selectedCard={selectedCard}
|
||||
selectedStatsRank={selectedStatsRank}
|
||||
setSelectedStatsRank={setSelectedStatsRank}
|
||||
selectedLanguagesLayout={selectedLanguagesLayout}
|
||||
setSelectedLanguagesLayout={setSelectedLanguagesLayout}
|
||||
selectedWakatimeLayout={selectedWakatimeLayout}
|
||||
setSelectedWakatimeLayout={setSelectedWakatimeLayout}
|
||||
showTitle={showTitle}
|
||||
setShowTitle={setShowTitle}
|
||||
showOwner={showOwner}
|
||||
setShowOwner={setShowOwner}
|
||||
descriptionLines={descriptionLines}
|
||||
setDescriptionLines={setDescriptionLines}
|
||||
customTitle={customTitle}
|
||||
setCustomTitle={setCustomTitle}
|
||||
langsCount={langsCount}
|
||||
setLangsCount={setLangsCount}
|
||||
hideValues={hideValues}
|
||||
setHideValues={setHideValues}
|
||||
showAllStats={showAllStats}
|
||||
setShowAllStats={setShowAllStats}
|
||||
showIcons={showIcons}
|
||||
setShowIcons={setShowIcons}
|
||||
includeAllCommits={includeAllCommits}
|
||||
setIncludeAllCommits={setIncludeAllCommits}
|
||||
enableAnimations={enableAnimations}
|
||||
setEnableAnimations={setEnableAnimations}
|
||||
selectedUserId={selectedUserId}
|
||||
setSelectedUserId={setSelectedUserId}
|
||||
repo={repo}
|
||||
setRepo={setRepo}
|
||||
gist={gist}
|
||||
setGist={setGist}
|
||||
wakatimeUser={wakatimeUser}
|
||||
setWakatimeUser={setWakatimeUser}
|
||||
usePercent={usePercent}
|
||||
setUsePercent={setUsePercent}
|
||||
fullSuffix={fullSuffix}
|
||||
options={cardOptions}
|
||||
onOptionChange={setCardOption}
|
||||
card={customizeCardBuilder}
|
||||
setStage={setStage}
|
||||
/>
|
||||
)}
|
||||
{stage === 3 && (
|
||||
<ThemeStage
|
||||
fullSuffix={fullSuffix}
|
||||
card={cardBuilder}
|
||||
theme={theme}
|
||||
category={cardCategory}
|
||||
onThemeChange={(theme) => {
|
||||
setTheme(theme);
|
||||
setStage(4);
|
||||
@@ -344,49 +267,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
)}
|
||||
{stage === 4 && (
|
||||
<DisplayStage
|
||||
filename={(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `${selectedUserId}_card`;
|
||||
case CardType.PIN:
|
||||
return `${repo}_card`;
|
||||
case CardType.GIST:
|
||||
return `gist_card`;
|
||||
case CardType.WAKATIME:
|
||||
return `${wakatimeUser}_card`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
link={(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `https://${HOST}/api${themeSuffix}`;
|
||||
|
||||
case CardType.PIN: {
|
||||
let myRepo = repo;
|
||||
if (!myRepo.includes("/")) {
|
||||
myRepo = `${userId}/${myRepo}`;
|
||||
}
|
||||
return `https://github.com/${myRepo}`;
|
||||
}
|
||||
case CardType.GIST:
|
||||
return gistUrl;
|
||||
case CardType.WAKATIME:
|
||||
return `https://wakatime.com/@${wakatimeUser}`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
themeSuffix={themeSuffix}
|
||||
filename={cardBuilder.filename()}
|
||||
link={cardDescriptor.link}
|
||||
theme={theme}
|
||||
card={themeBuilder}
|
||||
guestHint={
|
||||
isAuthenticated
|
||||
? null
|
||||
: `Replace the sample ${guestHint} with your own after copying your Markdown or URL!`
|
||||
: `Replace the sample ${cardDescriptor.guestHint} with your own after copying your Markdown or URL!`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
+24
-25
@@ -5,12 +5,15 @@ import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/Stat
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
|
||||
import { getFullSuffix } from "./getFullSuffix";
|
||||
import { buildCardUrl } from "./buildCardUrl";
|
||||
import type { CardOptions } from "./cardOptions";
|
||||
|
||||
const baseOptions = {
|
||||
selectedCard: CardType.STATS,
|
||||
const USER_ID = "john-github";
|
||||
|
||||
// Built inline instead of via `getDefaultCardOptions`, which imports
|
||||
// `constants.ts` and needs a `window` global these node-based tests lack.
|
||||
const baseOptions: CardOptions = {
|
||||
selectedUserId: "john",
|
||||
userId: "john-github",
|
||||
repo: "repo1",
|
||||
gist: "gist1",
|
||||
wakatimeUser: "wakaUser",
|
||||
@@ -30,15 +33,15 @@ const baseOptions = {
|
||||
usePercent: false,
|
||||
};
|
||||
|
||||
describe("getFullSuffix", () => {
|
||||
describe("buildCardUrl", () => {
|
||||
it("builds stats suffix with defaults", () => {
|
||||
const result = getFullSuffix(baseOptions);
|
||||
const result = buildCardUrl(USER_ID, CardType.STATS, baseOptions);
|
||||
|
||||
expect(result).toBe("?username=john");
|
||||
expect(result.toString()).toBe("?username=john");
|
||||
});
|
||||
|
||||
it("adds stats options", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.STATS, {
|
||||
...baseOptions,
|
||||
showIcons: true,
|
||||
includeAllCommits: true,
|
||||
@@ -46,73 +49,69 @@ describe("getFullSuffix", () => {
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"?username=john" +
|
||||
"&hide_title=true" +
|
||||
"&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented" +
|
||||
// commas are percent-encoded (%2C) now that params go through URLSearchParams
|
||||
"&show=reviews%2Cdiscussions_started%2Cdiscussions_answered%2Cprs_merged%2Cprs_merged_percentage%2Cprs_commented%2Cprs_reviewed%2Cissues_commented" +
|
||||
"&show_icons=true" +
|
||||
"&include_all_commits=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds top-langs suffix", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.TOP_LANGS,
|
||||
langsCount: 5,
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"/top-langs?username=john&hide_title=true&langs_count=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds pin suffix using userId not selectedUserId", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.PIN, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.PIN,
|
||||
showOwner: true,
|
||||
descriptionLines: 3,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds gist suffix", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.GIST, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.GIST,
|
||||
showOwner: true,
|
||||
});
|
||||
|
||||
expect(result).toBe("/gist?id=gist1&show_owner=true");
|
||||
expect(result.toString()).toBe("/gist?id=gist1&show_owner=true");
|
||||
});
|
||||
|
||||
it("builds wakatime suffix with percent and custom title", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.WAKATIME, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.WAKATIME,
|
||||
wakatimeUser: "waka",
|
||||
usePercent: true,
|
||||
customTitle: "My Stats",
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds non-default layouts", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.TOP_LANGS,
|
||||
selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" },
|
||||
});
|
||||
|
||||
expect(result).toBe("/top-langs?username=john&layout=compact");
|
||||
expect(result.toString()).toBe("/top-langs?username=john&layout=compact");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
import { cardUrl } from "../../models/CardUrl";
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
import type { CardOptions } from "./cardOptions";
|
||||
|
||||
export function buildCardUrl(
|
||||
userId: string,
|
||||
selectedCard: CardType,
|
||||
options: CardOptions,
|
||||
): CardUrlBuilder {
|
||||
const {
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
} = options;
|
||||
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS: {
|
||||
let url = cardUrl(CardType.STATS);
|
||||
if (selectedUserId) {
|
||||
url = url.username(selectedUserId);
|
||||
}
|
||||
if (selectedStatsRank !== STATS_DEFAULT_RANK) {
|
||||
if (selectedStatsRank.value === "hide_rank") {
|
||||
url = url.hideRank();
|
||||
} else {
|
||||
url = url.rankIcon(selectedStatsRank.value);
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
url = url.hideTitle();
|
||||
}
|
||||
if (customTitle) {
|
||||
url = url.customTitle(customTitle);
|
||||
}
|
||||
if (showAllStats) {
|
||||
url = url.show(
|
||||
"reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented",
|
||||
);
|
||||
}
|
||||
if (showIcons) {
|
||||
url = url.showIcons();
|
||||
}
|
||||
if (includeAllCommits) {
|
||||
url = url.includeAllCommits();
|
||||
}
|
||||
if (!enableAnimations) {
|
||||
url = url.disableAnimations();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.TOP_LANGS: {
|
||||
let url = cardUrl(CardType.TOP_LANGS);
|
||||
if (selectedUserId) {
|
||||
url = url.username(selectedUserId);
|
||||
}
|
||||
if (selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT) {
|
||||
if (selectedLanguagesLayout.value === "hide_progress") {
|
||||
url = url.hideProgress();
|
||||
} else {
|
||||
url = url.layout(selectedLanguagesLayout.value);
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
url = url.hideTitle();
|
||||
}
|
||||
if (langsCount) {
|
||||
url = url.langsCount(langsCount);
|
||||
}
|
||||
if (hideValues) {
|
||||
url = url.hideValues();
|
||||
}
|
||||
if (!enableAnimations) {
|
||||
url = url.disableAnimations();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.PIN: {
|
||||
/**
|
||||
* We should use the name of the logged-in user, not the value entered in the
|
||||
* username field in step 3.
|
||||
*
|
||||
* This input is not shown when the PIN card type is selected,
|
||||
* but it may still contain a different value if the user previously chose another card type.
|
||||
*
|
||||
* For example,
|
||||
* 1. the user could select the STATS card
|
||||
* 2. enter a username
|
||||
* 3. then go back and switch to the PIN card, leaving the old value behind.
|
||||
*
|
||||
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
|
||||
*/
|
||||
let url = cardUrl(CardType.PIN);
|
||||
if (userId) {
|
||||
url = url.username(userId);
|
||||
}
|
||||
if (repo) {
|
||||
url = url.repo(repo);
|
||||
}
|
||||
if (showOwner) {
|
||||
url = url.showOwner();
|
||||
}
|
||||
if (descriptionLines) {
|
||||
url = url.descriptionLines(descriptionLines);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.GIST: {
|
||||
let url = cardUrl(CardType.GIST);
|
||||
if (gist) {
|
||||
url = url.gistId(gist);
|
||||
}
|
||||
if (showOwner) {
|
||||
url = url.showOwner();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.WAKATIME: {
|
||||
let url = cardUrl(CardType.WAKATIME);
|
||||
if (wakatimeUser) {
|
||||
url = url.username(wakatimeUser);
|
||||
}
|
||||
if (selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT) {
|
||||
if (selectedWakatimeLayout.value === "hide_progress") {
|
||||
url = url.hideProgress().cardWidth(315);
|
||||
} else {
|
||||
url = url.layout(selectedWakatimeLayout.value);
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
url = url.hideTitle();
|
||||
}
|
||||
if (customTitle) {
|
||||
url = url.customTitle(customTitle);
|
||||
}
|
||||
if (langsCount) {
|
||||
url = url.langsCount(langsCount);
|
||||
}
|
||||
if (!enableAnimations) {
|
||||
url = url.disableAnimations();
|
||||
}
|
||||
if (usePercent) {
|
||||
url = url.displayFormat("percent");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
throw new Error(`unknown card type: ${selectedCard as string}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SelectOption } from "../../components/Generic/Select";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { DEMO_GIST, DEMO_REPO, DEMO_WAKATIME_USER } from "../../constants";
|
||||
|
||||
/**
|
||||
* All user-tunable card parameters collected during the customize stage.
|
||||
* Held as a single state object in `HomeScreen` and updated one key at a time.
|
||||
*/
|
||||
export interface CardOptions {
|
||||
selectedUserId: string;
|
||||
repo: string;
|
||||
gist: string;
|
||||
wakatimeUser: string;
|
||||
selectedStatsRank: SelectOption;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
showTitle: boolean;
|
||||
showOwner: boolean;
|
||||
descriptionLines: number | undefined;
|
||||
customTitle: string;
|
||||
langsCount: number | undefined;
|
||||
hideValues: boolean;
|
||||
showAllStats: boolean;
|
||||
showIcons: boolean;
|
||||
includeAllCommits: boolean;
|
||||
enableAnimations: boolean;
|
||||
usePercent: boolean;
|
||||
}
|
||||
|
||||
export function getDefaultCardOptions(userId: string): CardOptions {
|
||||
return {
|
||||
selectedUserId: userId,
|
||||
repo: DEMO_REPO,
|
||||
gist: DEMO_GIST,
|
||||
wakatimeUser: DEMO_WAKATIME_USER,
|
||||
selectedStatsRank: STATS_DEFAULT_RANK,
|
||||
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
|
||||
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
|
||||
showTitle: true,
|
||||
showOwner: false,
|
||||
descriptionLines: undefined,
|
||||
customTitle: "",
|
||||
langsCount: undefined,
|
||||
hideValues: false,
|
||||
showAllStats: false,
|
||||
showIcons: false,
|
||||
includeAllCommits: true,
|
||||
enableAnimations: true,
|
||||
usePercent: false,
|
||||
};
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { SelectOption } from "../../components/Generic/Select";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
|
||||
interface Options {
|
||||
userId: string;
|
||||
selectedUserId: string;
|
||||
selectedCard: CardType;
|
||||
repo: string;
|
||||
gist: string;
|
||||
wakatimeUser: string;
|
||||
selectedStatsRank: SelectOption;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
showTitle: boolean;
|
||||
showOwner: boolean;
|
||||
descriptionLines: number | undefined;
|
||||
customTitle: string;
|
||||
langsCount: number | undefined;
|
||||
hideValues: boolean;
|
||||
showAllStats: boolean;
|
||||
showIcons: boolean;
|
||||
includeAllCommits: boolean;
|
||||
enableAnimations: boolean;
|
||||
usePercent: boolean;
|
||||
}
|
||||
|
||||
export function getFullSuffix({
|
||||
userId,
|
||||
selectedCard,
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
}: Options): string {
|
||||
let fullSuffix = `${selectedCard === CardType.STATS ? "" : "/" + selectedCard}?`;
|
||||
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
fullSuffix += `username=${selectedUserId}`;
|
||||
break;
|
||||
case CardType.PIN:
|
||||
/**
|
||||
* We should use the name of the logged-in user, not the value entered in the
|
||||
* username field in step 3.
|
||||
*
|
||||
* This input is not shown when the PIN card type is selected,
|
||||
* but it may still contain a different value if the user previously chose another card type.
|
||||
*
|
||||
* For example,
|
||||
* 1. the user could select the STATS card
|
||||
* 2. enter a username
|
||||
* 3. then go back and switch to the PIN card, leaving the old value behind.
|
||||
*
|
||||
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
|
||||
*/
|
||||
fullSuffix += `username=${userId}&repo=${repo}`;
|
||||
break;
|
||||
case CardType.GIST:
|
||||
fullSuffix += `id=${gist}`;
|
||||
break;
|
||||
case CardType.WAKATIME:
|
||||
fullSuffix += `username=${wakatimeUser}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedStatsRank !== STATS_DEFAULT_RANK &&
|
||||
selectedCard === CardType.STATS
|
||||
) {
|
||||
fullSuffix += `&rank_icon=${selectedStatsRank.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT &&
|
||||
selectedCard === CardType.TOP_LANGS
|
||||
) {
|
||||
fullSuffix += `&layout=${selectedLanguagesLayout.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT &&
|
||||
selectedCard === CardType.WAKATIME
|
||||
) {
|
||||
fullSuffix += `&layout=${selectedWakatimeLayout.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!showTitle &&
|
||||
(selectedCard === CardType.STATS ||
|
||||
selectedCard === CardType.TOP_LANGS ||
|
||||
selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += "&hide_title=true";
|
||||
}
|
||||
|
||||
if (
|
||||
showOwner &&
|
||||
(selectedCard === CardType.PIN || selectedCard === CardType.GIST)
|
||||
) {
|
||||
fullSuffix += "&show_owner=true";
|
||||
}
|
||||
|
||||
if (descriptionLines && selectedCard === CardType.PIN) {
|
||||
fullSuffix += `&description_lines_count=${descriptionLines}`;
|
||||
}
|
||||
|
||||
if (
|
||||
customTitle &&
|
||||
(selectedCard === CardType.STATS || selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
const encodedTitle = encodeURIComponent(customTitle);
|
||||
fullSuffix += `&custom_title=${encodedTitle}`;
|
||||
}
|
||||
|
||||
if (
|
||||
langsCount &&
|
||||
(selectedCard === CardType.TOP_LANGS || selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += `&langs_count=${langsCount}`;
|
||||
}
|
||||
|
||||
if (hideValues && selectedCard === CardType.TOP_LANGS) {
|
||||
fullSuffix += `&hide_values=true`;
|
||||
}
|
||||
|
||||
if (showAllStats && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented`;
|
||||
}
|
||||
|
||||
if (showIcons && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show_icons=true`;
|
||||
}
|
||||
|
||||
if (includeAllCommits && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&include_all_commits=true`;
|
||||
}
|
||||
|
||||
if (
|
||||
!enableAnimations &&
|
||||
(selectedCard === CardType.STATS ||
|
||||
selectedCard === CardType.TOP_LANGS ||
|
||||
selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += `&disable_animations=${!enableAnimations}`;
|
||||
}
|
||||
|
||||
if (usePercent && selectedCard === CardType.WAKATIME) {
|
||||
fullSuffix += `&display_format=percent`;
|
||||
}
|
||||
|
||||
return fullSuffix;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { JSX, default as React } from "react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { CardImage } from "../../../components/Card/CardImage";
|
||||
import type { SelectOption } from "../../../components/Generic/Select";
|
||||
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
|
||||
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
|
||||
import { NumericSection } from "../../../components/Home/NumericSection";
|
||||
@@ -14,102 +13,77 @@ import {
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../constants";
|
||||
import { CardType } from "../../../models/CardType";
|
||||
import {
|
||||
CATEGORY_BY_CARD_TYPE,
|
||||
CardCategory,
|
||||
CardType,
|
||||
} from "../../../models/CardType";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import type { StageIndex } from "../../../models/Stage";
|
||||
import { useIsAuthenticated } from "../../../redux/selectors/userSelectors";
|
||||
import type { CardOptions } from "../cardOptions";
|
||||
|
||||
type Updater<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
/**
|
||||
* Extract the trailing path segments from pasted text, so pasting a full
|
||||
* GitHub URL yields just the username (1 segment), owner/repo (2), or Gist id (1).
|
||||
*/
|
||||
function pastedPathTail(text: string, segments: number): string {
|
||||
let value = text;
|
||||
if (value.endsWith("/")) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
const parts = value.split("/");
|
||||
if (parts.length > segments) {
|
||||
value = parts.slice(-segments).join("/");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @todo todo consider using React context API to avoid prop drilling */
|
||||
interface CustomizeStageProps {
|
||||
selectedCard: CardType;
|
||||
selectedStatsRank: SelectOption;
|
||||
setSelectedStatsRank: Updater<SelectOption>;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
setSelectedLanguagesLayout: Updater<SelectOption>;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
setSelectedWakatimeLayout: Updater<SelectOption>;
|
||||
selectedUserId: string;
|
||||
setSelectedUserId: Updater<string>;
|
||||
repo: string;
|
||||
setRepo: Updater<string>;
|
||||
gist: string;
|
||||
setGist: Updater<string>;
|
||||
wakatimeUser: string;
|
||||
setWakatimeUser: Updater<string>;
|
||||
showTitle: boolean;
|
||||
setShowTitle: Updater<boolean>;
|
||||
descriptionLines: number | undefined;
|
||||
setDescriptionLines: Updater<number | undefined>;
|
||||
showOwner: boolean;
|
||||
setShowOwner: Updater<boolean>;
|
||||
customTitle: string;
|
||||
setCustomTitle: Updater<string>;
|
||||
langsCount: number | undefined;
|
||||
setLangsCount: Updater<number | undefined>;
|
||||
hideValues: boolean;
|
||||
setHideValues: Updater<boolean>;
|
||||
showIcons: boolean;
|
||||
setShowIcons: Updater<boolean>;
|
||||
showAllStats: boolean;
|
||||
setShowAllStats: Updater<boolean>;
|
||||
includeAllCommits: boolean;
|
||||
setIncludeAllCommits: Updater<boolean>;
|
||||
enableAnimations: boolean;
|
||||
setEnableAnimations: Updater<boolean>;
|
||||
usePercent: boolean;
|
||||
setUsePercent: Updater<boolean>;
|
||||
fullSuffix: string;
|
||||
options: CardOptions;
|
||||
onOptionChange: <K extends keyof CardOptions>(
|
||||
key: K,
|
||||
value: CardOptions[K],
|
||||
) => void;
|
||||
card: CardUrlBuilder;
|
||||
setStage: (stageIndex: StageIndex) => void;
|
||||
}
|
||||
|
||||
export function CustomizeStage({
|
||||
selectedCard,
|
||||
selectedStatsRank,
|
||||
setSelectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
setSelectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
setSelectedWakatimeLayout,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
repo,
|
||||
setRepo,
|
||||
gist,
|
||||
setGist,
|
||||
wakatimeUser,
|
||||
setWakatimeUser,
|
||||
showTitle,
|
||||
setShowTitle,
|
||||
showOwner,
|
||||
setShowOwner,
|
||||
descriptionLines,
|
||||
setDescriptionLines,
|
||||
customTitle,
|
||||
setCustomTitle,
|
||||
langsCount,
|
||||
setLangsCount,
|
||||
hideValues,
|
||||
setHideValues,
|
||||
showIcons,
|
||||
setShowIcons,
|
||||
showAllStats,
|
||||
setShowAllStats,
|
||||
includeAllCommits,
|
||||
setIncludeAllCommits,
|
||||
enableAnimations,
|
||||
setEnableAnimations,
|
||||
usePercent,
|
||||
setUsePercent,
|
||||
fullSuffix,
|
||||
options,
|
||||
onOptionChange,
|
||||
card,
|
||||
setStage,
|
||||
}: CustomizeStageProps): JSX.Element {
|
||||
const cardType = selectedCard;
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const {
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
} = options;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2 p-10 rounded-sm bg-gray-200">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2 p-10 rounded-sm bg-base-200">
|
||||
{(cardType === CardType.STATS || cardType === CardType.TOP_LANGS) && (
|
||||
<TextSection
|
||||
title="Username"
|
||||
@@ -126,7 +100,7 @@ export function CustomizeStage({
|
||||
e.preventDefault();
|
||||
setStage(0);
|
||||
}}
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
log in
|
||||
</a>{" "}
|
||||
@@ -137,19 +111,16 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_USER}"`}
|
||||
value={selectedUserId}
|
||||
onValueChange={setSelectedUserId}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("selectedUserId", value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
let newValue = e.clipboardData.getData("text");
|
||||
// if the user pasted a full GitHub URL, extract username
|
||||
if (newValue.endsWith("/")) {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
const parts = newValue.split("/");
|
||||
if (parts.length > 1) {
|
||||
newValue = parts.slice(-1).join("/");
|
||||
}
|
||||
setSelectedUserId(newValue);
|
||||
onOptionChange(
|
||||
"selectedUserId",
|
||||
pastedPathTail(e.clipboardData.getData("text"), 1),
|
||||
);
|
||||
}}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
@@ -170,7 +141,7 @@ export function CustomizeStage({
|
||||
e.preventDefault();
|
||||
setStage(0);
|
||||
}}
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
log in
|
||||
</a>{" "}
|
||||
@@ -181,19 +152,16 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_REPO}"`}
|
||||
value={repo}
|
||||
onValueChange={setRepo}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("repo", value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
let newValue = e.clipboardData.getData("text");
|
||||
// if the user pasted a full GitHub URL, extract owner/repo
|
||||
if (newValue.endsWith("/")) {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
const parts = newValue.split("/");
|
||||
if (parts.length > 2) {
|
||||
newValue = parts.slice(-2).join("/");
|
||||
}
|
||||
setRepo(newValue);
|
||||
onOptionChange(
|
||||
"repo",
|
||||
pastedPathTail(e.clipboardData.getData("text"), 2),
|
||||
);
|
||||
}}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
@@ -214,7 +182,7 @@ export function CustomizeStage({
|
||||
e.preventDefault();
|
||||
setStage(0);
|
||||
}}
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
log in
|
||||
</a>{" "}
|
||||
@@ -225,19 +193,16 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_GIST}"`}
|
||||
value={gist}
|
||||
onValueChange={setGist}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("gist", value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
let newValue = e.clipboardData.getData("text");
|
||||
// if the user pasted a full GitHub URL, extract Gist ID
|
||||
if (newValue.endsWith("/")) {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
const parts = newValue.split("/");
|
||||
if (parts.length > 1) {
|
||||
newValue = parts.slice(-1).join("/");
|
||||
}
|
||||
setGist(newValue);
|
||||
onOptionChange(
|
||||
"gist",
|
||||
pastedPathTail(e.clipboardData.getData("text"), 1),
|
||||
);
|
||||
}}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
@@ -251,7 +216,7 @@ export function CustomizeStage({
|
||||
<a
|
||||
href="https://wakatime.com/"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
WakaTime
|
||||
</a>{" "}
|
||||
@@ -260,7 +225,9 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
|
||||
value={wakatimeUser}
|
||||
onValueChange={setWakatimeUser}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("wakatimeUser", value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
@@ -269,13 +236,17 @@ export function CustomizeStage({
|
||||
text="Show all available statistics."
|
||||
question="Show all stats?"
|
||||
checked={showAllStats}
|
||||
onCheckedChange={setShowAllStats}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showAllStats", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
<StatsRankSection
|
||||
selectedOption={selectedStatsRank}
|
||||
onOptionChange={setSelectedStatsRank}
|
||||
onOptionChange={(option) => {
|
||||
onOptionChange("selectedStatsRank", option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
@@ -284,7 +255,9 @@ export function CustomizeStage({
|
||||
text="Show icons next to all stats."
|
||||
question="Show icons?"
|
||||
checked={showIcons}
|
||||
onCheckedChange={setShowIcons}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showIcons", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
@@ -293,19 +266,25 @@ export function CustomizeStage({
|
||||
text="Count total commits or just commits of the last 365 days."
|
||||
question="Include all commits?"
|
||||
checked={includeAllCommits}
|
||||
onCheckedChange={setIncludeAllCommits}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("includeAllCommits", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.TOP_LANGS && (
|
||||
<LanguagesLayoutSection
|
||||
selectedLanguageLayoutOption={selectedLanguagesLayout}
|
||||
onLanguageLayoutOptionChange={setSelectedLanguagesLayout}
|
||||
onLanguageLayoutOptionChange={(option) => {
|
||||
onOptionChange("selectedLanguagesLayout", option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.WAKATIME && (
|
||||
<WakatimeLayoutSection
|
||||
selectedOption={selectedWakatimeLayout}
|
||||
onOptionChange={setSelectedWakatimeLayout}
|
||||
onOptionChange={(option) => {
|
||||
onOptionChange("selectedWakatimeLayout", option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.TOP_LANGS ||
|
||||
@@ -320,7 +299,9 @@ export function CustomizeStage({
|
||||
</>
|
||||
}
|
||||
value={langsCount}
|
||||
onValueChange={setLangsCount}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("langsCount", value);
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
/>
|
||||
@@ -331,7 +312,9 @@ export function CustomizeStage({
|
||||
text="Hide language percentages or bytes while keeping the selected layout visible."
|
||||
question="Hide values?"
|
||||
checked={hideValues}
|
||||
onCheckedChange={setHideValues}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("hideValues", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.WAKATIME && (
|
||||
@@ -340,7 +323,9 @@ export function CustomizeStage({
|
||||
text="Show time spent in hours or percentages."
|
||||
question="Show percentages?"
|
||||
checked={usePercent}
|
||||
onCheckedChange={setUsePercent}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("usePercent", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.STATS ||
|
||||
@@ -351,7 +336,9 @@ export function CustomizeStage({
|
||||
text="Shows a title at the top of the card."
|
||||
question="Show title?"
|
||||
checked={showTitle}
|
||||
onCheckedChange={setShowTitle}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showTitle", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
|
||||
@@ -366,7 +353,9 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder='e.g. "My GitHub Stats"'
|
||||
value={customTitle}
|
||||
onValueChange={setCustomTitle}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("customTitle", value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.STATS ||
|
||||
@@ -377,16 +366,20 @@ export function CustomizeStage({
|
||||
// text="Enable Animations."
|
||||
question="enable animations?"
|
||||
checked={enableAnimations}
|
||||
onCheckedChange={setEnableAnimations}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("enableAnimations", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.PIN || cardType === CardType.GIST) && (
|
||||
{CATEGORY_BY_CARD_TYPE[cardType] === CardCategory.REPO && (
|
||||
<CheckboxSection
|
||||
title="Show Owner?"
|
||||
text="Shows the repo owner's name next to the repo name."
|
||||
question="Show owner?"
|
||||
checked={showOwner}
|
||||
onCheckedChange={setShowOwner}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showOwner", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.PIN && (
|
||||
@@ -401,7 +394,9 @@ export function CustomizeStage({
|
||||
</>
|
||||
}
|
||||
value={descriptionLines}
|
||||
onValueChange={setDescriptionLines}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("descriptionLines", value);
|
||||
}}
|
||||
min={1}
|
||||
max={3}
|
||||
/>
|
||||
@@ -411,7 +406,7 @@ export function CustomizeStage({
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
customization documentation
|
||||
</a>{" "}
|
||||
@@ -420,7 +415,7 @@ export function CustomizeStage({
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
|
||||
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
|
||||
<CardImage imageSrc={fullSuffix} stage={2} />
|
||||
<CardImage card={card} stage={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { saveSvgAsPng } from "save-svg-as-png";
|
||||
|
||||
import { CardImage } from "../../../components/Card/CardImage";
|
||||
import { getCardThemeBackdrop } from "../../../components/Card/themeBackdrop";
|
||||
import { Button } from "../../../components/Generic/Button";
|
||||
import { HOST } from "../../../constants";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||
|
||||
interface DisplayStageProps {
|
||||
filename: string;
|
||||
link: string;
|
||||
themeSuffix: string;
|
||||
theme: string;
|
||||
card: CardUrlBuilder;
|
||||
guestHint: string | null;
|
||||
}
|
||||
|
||||
export function DisplayStage({
|
||||
filename,
|
||||
link,
|
||||
themeSuffix,
|
||||
theme,
|
||||
card,
|
||||
guestHint,
|
||||
}: DisplayStageProps): JSX.Element {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const downloadPNG = () => {
|
||||
saveSvgAsPng(
|
||||
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
|
||||
@@ -34,7 +40,7 @@ export function DisplayStage({
|
||||
|
||||
const copyMarkdown = () => {
|
||||
void navigator.clipboard.writeText(
|
||||
`[](${link})`,
|
||||
`[})](${link})`,
|
||||
);
|
||||
toast.info("Copied to Clipboard!", {
|
||||
position: "bottom-right",
|
||||
@@ -47,7 +53,7 @@ export function DisplayStage({
|
||||
};
|
||||
|
||||
const copyUrl = () => {
|
||||
void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
|
||||
void navigator.clipboard.writeText(card.toApiUrl(HOST));
|
||||
toast.info("Copied to Clipboard!", {
|
||||
position: "bottom-right",
|
||||
autoClose: 1500,
|
||||
@@ -61,7 +67,7 @@ export function DisplayStage({
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2">
|
||||
<div className="p-10 rounded-sm bg-gray-200">
|
||||
<div className="p-10 rounded-sm bg-base-200">
|
||||
<div className="flex flex-col items-center">
|
||||
{[
|
||||
{
|
||||
@@ -82,10 +88,8 @@ export function DisplayStage({
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.title}
|
||||
className={clsx("m-4 w-60 flex justify-center", {
|
||||
"bg-blue-500 hover:bg-blue-600 text-white": item.highlight,
|
||||
"bg-white hover:bg-gray-100 text-black": !item.highlight,
|
||||
})}
|
||||
variant={item.highlight ? "primary" : "soft"}
|
||||
className="m-4 w-60 flex justify-center"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.title}
|
||||
@@ -96,10 +100,14 @@ export function DisplayStage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
|
||||
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
|
||||
<div
|
||||
className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32 rounded p-4"
|
||||
style={{ background: getCardThemeBackdrop(theme, isDark) }}
|
||||
>
|
||||
<CardImage
|
||||
imageSrc={`${themeSuffix}&disable_animations=true`}
|
||||
card={card.disableAnimations()}
|
||||
stage={4}
|
||||
className="flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function LoginAccountDeleteModal(
|
||||
<div className="fixed left-0 top-0 w-full h-full">
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<div
|
||||
className="w-96 p-4 bg-white rounded-sm border-2 border-gray-200 text-gray-700"
|
||||
className="w-96 p-4 bg-base-100 rounded-sm border-2 border-base-300 text-base-content"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<p className="mb-1 text-2xl">Delete Account</p>
|
||||
@@ -57,16 +57,10 @@ export function LoginAccountDeleteModal(
|
||||
</p>
|
||||
<br />
|
||||
<div className="flex flex-wrap">
|
||||
<Button
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Button variant="error" className="ml-auto" onClick={onConfirm}>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -56,14 +56,17 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
<a
|
||||
href={`https://${HOST}/api/downgrade?user_key=${userKey as string}`}
|
||||
>
|
||||
<Button className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100">
|
||||
<Button
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">
|
||||
Downgrade to Public Access
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
Switch to public access if you prefer not to share private
|
||||
contributions.
|
||||
</p>
|
||||
@@ -71,14 +74,17 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[320px] text-white bg-blue-500 hover:bg-blue-600">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">
|
||||
Upgrade to Private Access
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
Upgrade to include contributions in private repositories for more
|
||||
complete and accurate stats.
|
||||
</p>
|
||||
@@ -89,12 +95,15 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
{/* Delete Account Button */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
<span className="xl:text-lg text-red-600">Delete Account</span>
|
||||
<span className="xl:text-lg text-error text-shadow-none">
|
||||
Delete Account
|
||||
</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
This will delete your GitHub-Stats-Extended account and then redirect
|
||||
you to a GitHub screen where you can revoke your access token.
|
||||
</p>
|
||||
@@ -103,12 +112,13 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
{/* Logout Button */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="xl:text-lg">Log Out</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
Log out from GitHub-Stats-Extended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function LoginBox(props: LoginBoxProps): JSX.Element {
|
||||
<div className="h-full flex flex-wrap">
|
||||
<div className={clsx("md:flex", { "opacity-25": isOpaque })}>
|
||||
<div className="lg:block lg:w-3/5 lg:p-8">
|
||||
<div className="bg-gray-200 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
|
||||
<div className="bg-base-300 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,23 +7,26 @@ import {
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../../constants";
|
||||
import { CardType } from "../../../../models/CardType";
|
||||
import { cardUrl } from "../../../../models/CardUrl";
|
||||
import { useTheme } from "../../../../redux/selectors/themeSelectors";
|
||||
|
||||
const cards: Array<{ demoImageSrc: string }> = [
|
||||
{
|
||||
demoImageSrc: `/pin?repo=${DEMO_REPO}&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/top-langs?username=${DEMO_USER}&langs_count=4&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `?username=${DEMO_USER}&include_all_commits=true&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/gist?id=${DEMO_GIST}&disable_animations=true`,
|
||||
},
|
||||
const cards = [
|
||||
cardUrl(CardType.PIN).repo(DEMO_REPO).disableAnimations(),
|
||||
cardUrl(CardType.TOP_LANGS)
|
||||
.username(DEMO_USER)
|
||||
.langsCount(4)
|
||||
.disableAnimations(),
|
||||
cardUrl(CardType.STATS)
|
||||
.username(DEMO_USER)
|
||||
.includeAllCommits()
|
||||
.disableAnimations(),
|
||||
cardUrl(CardType.WAKATIME)
|
||||
.username(DEMO_WAKATIME_USER)
|
||||
.langsCount(6)
|
||||
.cardWidth(450)
|
||||
.disableAnimations(),
|
||||
cardUrl(CardType.GIST).gistId(DEMO_GIST).disableAnimations(),
|
||||
];
|
||||
|
||||
function getCardXPosition(cardIndex: number): number {
|
||||
@@ -39,22 +42,28 @@ function getCardXPosition(cardIndex: number): number {
|
||||
}
|
||||
|
||||
export function LoginBoxDemoCards(): JSX.Element {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full lg:w-2/5 flex lg:flex-col lg:p-8 relative overflow-hidden">
|
||||
<div className="relative w-full h-full">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.demoImageSrc}
|
||||
style={{
|
||||
left: `${getCardXPosition(index)}%`,
|
||||
position: "relative",
|
||||
zoom: "0.5",
|
||||
marginBottom: "1%",
|
||||
}}
|
||||
>
|
||||
<CardImage imageSrc={card.demoImageSrc} compact={false} stage={0} />
|
||||
</div>
|
||||
))}
|
||||
{cards.map((card, index) => {
|
||||
// Show dark-themed demo cards in dark mode so they fit the surroundings.
|
||||
const demoCard = isDark ? card.theme("github_dark") : card;
|
||||
return (
|
||||
<div
|
||||
key={demoCard.toString()}
|
||||
style={{
|
||||
left: `${getCardXPosition(index)}%`,
|
||||
position: "relative",
|
||||
zoom: "0.5",
|
||||
marginBottom: "1%",
|
||||
}}
|
||||
>
|
||||
<CardImage card={demoCard} compact={false} stage={0} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,24 +20,30 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
||||
<LoginBox>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<a href={GITHUB_PUBLIC_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[260px] text-white bg-blue-500 hover:bg-blue-600">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 flex justify-center items-center w-[260px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">GitHub Public Access</span>
|
||||
GitHub Public Access
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/80 flex-1">
|
||||
Generate stats based on your contributions in public repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100">
|
||||
<Button
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[260px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">GitHub Private Access</span>
|
||||
GitHub Private Access
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/80 flex-1">
|
||||
Include contributions from private repositories for more complete and
|
||||
accurate stats.
|
||||
</p>
|
||||
@@ -45,12 +51,13 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[260px]"
|
||||
onClick={onContinueAsGuest}
|
||||
>
|
||||
<span className="ml-2 xl:text-lg">Continue as Guest</span>
|
||||
Continue as Guest
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/80 flex-1">
|
||||
Explore options using sample data. Insert your own username in the
|
||||
last step.
|
||||
</p>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../constants";
|
||||
import { CardType } from "../../../models/CardType";
|
||||
import { cardUrl } from "../../../models/CardUrl";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||
import { useUserId } from "../../../redux/selectors/userSelectors";
|
||||
|
||||
interface SelectCardStageProps {
|
||||
@@ -21,12 +24,13 @@ export function SelectCardStage({
|
||||
onCardTypeChange,
|
||||
}: SelectCardStageProps): JSX.Element {
|
||||
const userId = useUserId(DEMO_USER);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const options = useMemo<
|
||||
Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
demoImageSrc: string;
|
||||
demoCard: CardUrlBuilder;
|
||||
cardType: CardType;
|
||||
}>
|
||||
>(
|
||||
@@ -34,33 +38,36 @@ export function SelectCardStage({
|
||||
{
|
||||
title: "GitHub Stats Card",
|
||||
description: "your overall GitHub statistics",
|
||||
demoImageSrc: `?username=${userId}&include_all_commits=true`,
|
||||
demoCard: cardUrl(CardType.STATS).username(userId).includeAllCommits(),
|
||||
cardType: CardType.STATS,
|
||||
},
|
||||
{
|
||||
title: "Top Languages Card",
|
||||
description: "your most frequently used languages",
|
||||
demoImageSrc: `/top-langs?username=${userId}&langs_count=4`,
|
||||
demoCard: cardUrl(CardType.TOP_LANGS).username(userId).langsCount(4),
|
||||
cardType: CardType.TOP_LANGS,
|
||||
},
|
||||
{
|
||||
title: "GitHub Extra Pin",
|
||||
description:
|
||||
"pin more than 6 repositories in your profile using a GitHub profile readme",
|
||||
demoImageSrc: `/pin?repo=${DEMO_REPO}`,
|
||||
demoCard: cardUrl(CardType.PIN).repo(DEMO_REPO),
|
||||
cardType: CardType.PIN,
|
||||
},
|
||||
{
|
||||
title: "GitHub Gist Pin",
|
||||
description:
|
||||
"pin gists in your GitHub profile using a GitHub profile readme",
|
||||
demoImageSrc: `/gist?id=${DEMO_GIST}`,
|
||||
demoCard: cardUrl(CardType.GIST).gistId(DEMO_GIST),
|
||||
cardType: CardType.GIST,
|
||||
},
|
||||
{
|
||||
title: "WakaTime Stats Card",
|
||||
description: "your coding activity from WakaTime",
|
||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450`,
|
||||
demoCard: cardUrl(CardType.WAKATIME)
|
||||
.username(DEMO_WAKATIME_USER)
|
||||
.langsCount(6)
|
||||
.cardWidth(450),
|
||||
cardType: CardType.WAKATIME,
|
||||
},
|
||||
],
|
||||
@@ -69,25 +76,31 @@ export function SelectCardStage({
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
{options.map((card) => (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={card.cardType}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCardTypeChange(card.cardType);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
imageSrc={card.demoImageSrc}
|
||||
selected={selectedCardType === card.cardType}
|
||||
fixedSize
|
||||
stage={1}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{options.map((card) => {
|
||||
// Show dark-themed demo cards in dark mode so they fit the surroundings.
|
||||
const demoCard = isDark
|
||||
? card.demoCard.theme("github_dark")
|
||||
: card.demoCard;
|
||||
return (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={card.cardType}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCardTypeChange(card.cardType);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
card={demoCard}
|
||||
selected={selectedCardType === card.cardType}
|
||||
fixedSize
|
||||
stage={1}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { ThemeName } from "@stats-organization/github-readme-stats-core";
|
||||
import { themes } from "@stats-organization/github-readme-stats-core";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Card } from "../../../components/Card/Card";
|
||||
import {
|
||||
getCardThemeBackdrop,
|
||||
getThemeSortRank,
|
||||
} from "../../../components/Card/themeBackdrop";
|
||||
import { CardCategory } from "../../../models/CardType";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||
|
||||
const excludedThemes = [
|
||||
const excludedThemes: Array<ThemeName> = [
|
||||
"default",
|
||||
"default_repocard",
|
||||
"github_dark",
|
||||
"merko",
|
||||
"blue-green",
|
||||
"gotham",
|
||||
@@ -12,53 +23,88 @@ const excludedThemes = [
|
||||
"holi",
|
||||
];
|
||||
|
||||
const themeList = Object.keys(themes).filter(
|
||||
(myTheme) => !excludedThemes.includes(myTheme),
|
||||
const allThemeNames = Object.keys(themes) as Array<ThemeName>;
|
||||
|
||||
// Light themes first, adaptive themes in the middle, dark themes last.
|
||||
const forPicker = (names: ReadonlyArray<ThemeName>) =>
|
||||
names
|
||||
.filter((name) => !excludedThemes.includes(name))
|
||||
.sort((a, b) => getThemeSortRank(a) - getThemeSortRank(b));
|
||||
|
||||
// Some themes come in pairs: `X_repocard` for the repo and gist cards, `X` for
|
||||
// the stats, top languages and WakaTime cards. Each side offers only its own
|
||||
// variant, so the theme Home.tsx starts on is always one of the entries here.
|
||||
//
|
||||
// Keep in sync with `generateTable` in `packages/core/scripts/generate-theme-readme.js`,
|
||||
// which splits the theme README tables by the same rule.
|
||||
const repoCardThemes = forPicker(
|
||||
allThemeNames.filter(
|
||||
(name) => !allThemeNames.includes(`${name}_repocard` as ThemeName),
|
||||
),
|
||||
);
|
||||
|
||||
const nonRepoCardThemes = forPicker(
|
||||
allThemeNames.filter((name) => !name.endsWith("_repocard")),
|
||||
);
|
||||
|
||||
interface ThemeStageProps {
|
||||
fullSuffix: string;
|
||||
card: CardUrlBuilder;
|
||||
theme: string;
|
||||
onThemeChange: (theme: string) => void;
|
||||
category: CardCategory;
|
||||
onThemeChange: (theme: ThemeName) => void;
|
||||
}
|
||||
|
||||
export function ThemeStage({
|
||||
theme,
|
||||
fullSuffix,
|
||||
card,
|
||||
category,
|
||||
onThemeChange,
|
||||
}: ThemeStageProps): JSX.Element {
|
||||
const { isDark } = useTheme();
|
||||
const themeList =
|
||||
category === CardCategory.REPO ? repoCardThemes : nonRepoCardThemes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap">
|
||||
{themeList.map((myTheme) => (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={myTheme}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onThemeChange(myTheme);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={myTheme}
|
||||
description=""
|
||||
imageSrc={`${fullSuffix}&theme=${myTheme}`}
|
||||
selected={theme === myTheme}
|
||||
stage={3}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{themeList.map((myTheme) => {
|
||||
const themeColors = themes[myTheme];
|
||||
return (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={myTheme}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onThemeChange(myTheme);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={myTheme}
|
||||
description=""
|
||||
card={card.theme(myTheme)}
|
||||
selected={theme === myTheme}
|
||||
stage={3}
|
||||
backgroundColor={getCardThemeBackdrop(myTheme, isDark)}
|
||||
titleColor={`#${
|
||||
myTheme === "ambient_gradient" && !isDark
|
||||
? (themes["ambient_gradient"].bg_color.split(",")[1] ?? "")
|
||||
: themeColors.title_color
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pl-10 pr-10">
|
||||
For more theme options check the{" "}
|
||||
{"For more theme options check the "}
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md#themes"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
customization documentation
|
||||
</a>{" "}
|
||||
after you copied your card URL in step 5.
|
||||
</a>
|
||||
{" after you copied your card URL in step 5."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import axios from "axios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
HOST,
|
||||
} from "../../constants";
|
||||
import { CardType } from "../../models/CardType";
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
/** Per-card metadata for the final display stage. */
|
||||
export interface CardDescriptor {
|
||||
/** Sample identity shown to guests (e.g. a sample username or repo). */
|
||||
guestHint: string;
|
||||
/** URL the card image links to when clicked. */
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface UseCardDescriptorOptions {
|
||||
selectedCard: CardType;
|
||||
/** Themed builder used to derive the stats/top-langs card link. */
|
||||
themeBuilder: CardUrlBuilder;
|
||||
repo: string;
|
||||
userId: string;
|
||||
wakatimeUser: string;
|
||||
gist: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-card metadata (guest hint + link) for the display stage,
|
||||
* fetching the Gist's public URL as a side effect when needed.
|
||||
*/
|
||||
export function useCardDescriptor({
|
||||
selectedCard,
|
||||
themeBuilder,
|
||||
repo,
|
||||
userId,
|
||||
wakatimeUser,
|
||||
gist,
|
||||
}: UseCardDescriptorOptions): CardDescriptor {
|
||||
const [gistUrl, setGistUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchGistURL() {
|
||||
try {
|
||||
const result = await axios.get<{ html_url: string }>(
|
||||
`https://api.github.com/gists/${gist}`,
|
||||
);
|
||||
setGistUrl(result.data.html_url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
void fetchGistURL();
|
||||
}, [gist]);
|
||||
|
||||
return useMemo<CardDescriptor>(() => {
|
||||
const pinRepo = repo.includes("/") ? repo : `${userId}/${repo}`;
|
||||
const statsCardLink = themeBuilder.toApiUrl(HOST);
|
||||
|
||||
const descriptors: Record<CardType, CardDescriptor> = {
|
||||
[CardType.STATS]: {
|
||||
guestHint: `username "${DEMO_USER}"`,
|
||||
link: statsCardLink,
|
||||
},
|
||||
[CardType.TOP_LANGS]: {
|
||||
guestHint: `username "${DEMO_USER}"`,
|
||||
link: statsCardLink,
|
||||
},
|
||||
[CardType.PIN]: {
|
||||
guestHint: `repo "${DEMO_REPO}"`,
|
||||
link: `https://github.com/${pinRepo}`,
|
||||
},
|
||||
[CardType.GIST]: {
|
||||
guestHint: `Gist ID "${DEMO_GIST}"`,
|
||||
link: gistUrl,
|
||||
},
|
||||
[CardType.WAKATIME]: {
|
||||
guestHint: `WakaTime username "${DEMO_WAKATIME_USER}"`,
|
||||
link: `https://wakatime.com/@${wakatimeUser}`,
|
||||
},
|
||||
};
|
||||
|
||||
return descriptors[selectedCard];
|
||||
}, [selectedCard, themeBuilder, repo, userId, gistUrl, wakatimeUser]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { isDarkTheme, setTheme as setThemeAction } from "../slices/theme";
|
||||
import type { StoreState } from "../store";
|
||||
|
||||
/**
|
||||
* Reads the active DaisyUI theme from the Redux store and exposes a setter
|
||||
* that persists the choice and updates the `data-theme` attribute.
|
||||
*/
|
||||
export function useTheme(): {
|
||||
theme: string;
|
||||
isDark: boolean;
|
||||
setTheme: (theme: string) => void;
|
||||
} {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useSelector((state: StoreState) => state.theme.theme);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(next: string) => {
|
||||
dispatch(setThemeAction(next));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return { theme, isDark: isDarkTheme(theme), setTheme };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface ThemeOption {
|
||||
/** DaisyUI theme name, used as the `data-theme` value. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** Whether the theme has a dark background (drives toast styling, etc.). */
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes exposed in the theme picker.
|
||||
* Keep in sync with the `themes:` list in `index.css`.
|
||||
*/
|
||||
export const THEMES: ReadonlyArray<ThemeOption> = [
|
||||
{ name: "light", label: "Light", isDark: false },
|
||||
{ name: "dark", label: "Dark", isDark: true },
|
||||
];
|
||||
|
||||
const DEFAULT_LIGHT_THEME = "light";
|
||||
const DEFAULT_DARK_THEME = "dark";
|
||||
|
||||
function isValidTheme(name: string | null): name is string {
|
||||
return !!name && THEMES.some((theme) => theme.name === name);
|
||||
}
|
||||
|
||||
export function isDarkTheme(name: string): boolean {
|
||||
return THEMES.find((theme) => theme.name === name)?.isDark ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Exported so the inferred store type can name it (see the note in `user.ts`).
|
||||
*/
|
||||
export interface ThemeState {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
function getInitialTheme(): string {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (isValidTheme(stored)) {
|
||||
return stored;
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? DEFAULT_DARK_THEME
|
||||
: DEFAULT_LIGHT_THEME;
|
||||
}
|
||||
|
||||
const initialState: ThemeState = {
|
||||
theme: getInitialTheme(),
|
||||
};
|
||||
|
||||
const themeSlice = createSlice({
|
||||
name: "theme",
|
||||
initialState,
|
||||
reducers: {
|
||||
setTheme: (state, action: PayloadAction<string>) => {
|
||||
const theme = action.payload;
|
||||
localStorage.setItem("theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
state.theme = theme;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setTheme } = themeSlice.actions;
|
||||
|
||||
export default themeSlice.reducer;
|
||||
@@ -3,11 +3,13 @@ import { configureStore } from "@reduxjs/toolkit";
|
||||
import { USE_LOGGER } from "../constants";
|
||||
|
||||
import { loggerMiddleware } from "./logger";
|
||||
import theme from "./slices/theme";
|
||||
import user from "./slices/user";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user,
|
||||
theme,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => {
|
||||
const middleware = getDefaultMiddleware();
|
||||
|
||||
+566
-263
File diff suppressed because it is too large
Load Diff
+56
-59
@@ -1,12 +1,12 @@
|
||||
# Deploy on your own
|
||||
# Run It Yourself
|
||||
|
||||
Since the GitHub API only allows a limited number of requests per hour, my `https://github-stats-extended.vercel.app/api` could possibly hit the rate limiter. If you deploy it yourself via GitHub Actions or your own hosted instance, then you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
|
||||
We cache generated cards for a few hours or days to avoid potential rate-limiting in the GitHub API or on Vercel. If you want to set your own cache duration or you want to include private contributions in your stats without granting our hosted version of GitHub-Stats-Extended access to your private contributions, you can run GitHub-Stats-Extended on your own.
|
||||
|
||||
GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting takes more work and can serve fresher stats (with caching).
|
||||
GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting GitHub-Stats-Extended on Vercel takes more work and can serve fresher stats (with caching).
|
||||
|
||||
## GitHub Actions
|
||||
## GitHub Action
|
||||
|
||||
GitHub Actions generates static SVGs and avoids per-request API calls. By default it uses `GITHUB_TOKEN` (public stats only), for private stats, set a [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) as a secret and pass it to the action instead.
|
||||
With [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) you can generate static cards in your GitHub Actions workflow, commit them to your profile repository, and embed them directly from there. This avoids any per-request API calls.
|
||||
|
||||
Create `/.github/workflows/grs.yml` in your profile repo (`USERNAME/USERNAME`):
|
||||
|
||||
@@ -15,17 +15,21 @@ name: Update README cards
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
- cron: "0 0 * * *" # Runs once daily at midnight
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Generate stats card
|
||||
uses: readme-tools/github-readme-stats-action@v1
|
||||
uses: stats-organization/github-readme-stats-action@v2
|
||||
with:
|
||||
card: stats
|
||||
options: username=${{ github.repository_owner }}&show_icons=true
|
||||
@@ -34,14 +38,14 @@ jobs:
|
||||
|
||||
- name: Commit cards
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "github-actions@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add profile/*.svg
|
||||
git commit -m "Update README cards" || exit 0
|
||||
git push
|
||||
```
|
||||
|
||||
Then embed from your profile README:
|
||||
Then embed from your [profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme):
|
||||
|
||||
```md
|
||||

|
||||
@@ -49,7 +53,7 @@ Then embed from your profile README:
|
||||
|
||||
See more options and examples in the [GitHub Readme Stats Action README](https://github.com/stats-organization/github-readme-stats-action#readme).
|
||||
|
||||
## Self-hosted (Vercel/Other)
|
||||
## Self-hosted on Vercel
|
||||
|
||||
Running your own instance avoids public rate limits and gives you full control over caching, tokens, and private stats.
|
||||
|
||||
@@ -61,52 +65,49 @@ Selecting the right scopes for your token is important in case you want to displ
|
||||
|
||||
#### Classic token
|
||||
|
||||
* Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Tokens (classic)](https://github.com/settings/tokens).
|
||||
* Click on `Generate new token -> Generate new token (classic)`.
|
||||
* Scopes to select:
|
||||
* repo
|
||||
* read:user
|
||||
* Click on `Generate token` and copy it.
|
||||
- Go to [Account → Settings → Developer Settings → Personal access tokens → Tokens (classic)](https://github.com/settings/tokens).
|
||||
- Click on `Generate new token → Generate new token (classic)`.
|
||||
- Scopes to select:
|
||||
- repo
|
||||
- read:user
|
||||
- Click on `Generate token` and copy it.
|
||||
|
||||
#### Fine-grained token
|
||||
|
||||
> [!WARNING]\
|
||||
> This limits the scope of commits to public repositories only.
|
||||
|
||||
* Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Fine-grained tokens](https://github.com/settings/personal-access-tokens).
|
||||
* Click on `Generate new token -> Generate new token`.
|
||||
* Enter a token name
|
||||
* Select an expiration date
|
||||
* Select `All repositories`
|
||||
* Scopes to select under `Permissions`:
|
||||
* Commit statuses: read-only
|
||||
* Contents: read-only
|
||||
* Issues: read-only
|
||||
* Metadata: read-only (added automatically when selecting above scopes)
|
||||
* Pull requests: read-only
|
||||
* Click on `Generate token` and copy it.
|
||||
- Go to [Account → Settings → Developer Settings → Personal access tokens → Fine-grained tokens](https://github.com/settings/personal-access-tokens).
|
||||
- Click on `Generate new token → Generate new token`.
|
||||
- Enter a token name
|
||||
- Select an expiration date
|
||||
- Select `All repositories`
|
||||
- Scopes to select under `Permissions`:
|
||||
- Commit statuses: read-only
|
||||
- Contents: read-only
|
||||
- Issues: read-only
|
||||
- Metadata: read-only (added automatically when selecting above scopes)
|
||||
- Pull requests: read-only
|
||||
- Click on `Generate token` and copy it.
|
||||
|
||||
### On Vercel
|
||||
|
||||
<b>:film\_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107)</b>
|
||||
|
||||
Click on the deploy button to get started!
|
||||
|
||||
[](https://vercel.com/import/project?template=https://github.com/stats-organization/github-stats-extended)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/stats-organization/github-stats-extended&root-directory=apps/backend)
|
||||
|
||||
<details>
|
||||
<summary><b>:hammer_and_wrench: Recommended: Step-by-step guide on setting up your own Vercel instance</b></summary>
|
||||
<b>Recommended: Step-by-step guide on setting up your own Vercel instance</b>
|
||||
|
||||
1. Go to [vercel.com](https://vercel.com/).
|
||||
2. Click on `Log in`.
|
||||

|
||||

|
||||
3. Sign in with GitHub by pressing `Continue with GitHub`.
|
||||

|
||||

|
||||
4. Sign in to GitHub and allow access to all repositories if prompted.
|
||||
5. Fork this repo.
|
||||
5. Fork this repo. Disable "Copy the `master` branch only".
|
||||
6. Go back to your [Vercel dashboard](https://vercel.com/dashboard).
|
||||
7. To import a project, click the `Add New...` button and select the `Project` option.
|
||||

|
||||

|
||||
8. Search for the forked Git Repository and import it by clicking the `Import` button.
|
||||
9. Create a Personal Access Token (PAT) as described in the [previous section](#first-step-get-your-personal-access-token-pat).
|
||||
10. Add the PAT as an environment variable named `PAT_1` (as shown).
|
||||
@@ -119,30 +120,26 @@ Click on the deploy button to get started!
|
||||
3. Now you can make the variable `Sensitive` by checking the checkbox.
|
||||

|
||||
11. As `Root directory` select the `apps/backend` folder.
|
||||
12. Click deploy, and you're good to go. See your domains to use the API!
|
||||
13. optional: add an SQL database; by using e.g. the ["Nile" integration](https://vercel.com/marketplace/nile) or by manually setting the environment variable `POSTGRES_URL`
|
||||
14. optional: [create your own OAuth App](https://github.com/settings/developers) and set environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly
|
||||
15. optional: in addition to the Vercel project based on the `apps/backend` folder, create a second project based on the `apps/frontend` folder. No environment variables needed.
|
||||
16. optional: set the environment variable `TURBO_PLATFORM_ENV_DISABLED` to `true` to disable the build-time warning from [turbo](https://turborepo.dev/) about environment variables missing from "turbo.json" - This warning is not relevant in our project.
|
||||
12. Click deploy.
|
||||
13. Point your Vercel project at the `release` branch instead of `master`.
|
||||
`master` contains unreleased, potentially unstable code, while `release` contains the latest stable release.
|
||||
1. In your Vercel project settings, go to `Environments → Production` and change the tracked branch from `master` to `release`.
|
||||
2. Go to `Deployments → ... → Create Deployment`, select `release` and click `Deploy to production`.
|
||||
14. See your domains to use the API!
|
||||
|
||||
</details>
|
||||
### Optional steps
|
||||
|
||||
### On other platforms
|
||||
#### Add an SQL database
|
||||
|
||||
> [!WARNING]
|
||||
> This way of using GitHub-Stats-Extended is not officially supported and was added to cater to some particular use cases where Vercel could not be used (e.g. [#2341](https://github.com/anuraghazra/github-readme-stats/discussions/2341)). The support for this method, therefore, is limited.
|
||||
Add an SQL database, either through an integration such as ["Nile"](https://vercel.com/marketplace/nile), or by manually setting the environment variable `POSTGRES_URL`.
|
||||
|
||||
<details>
|
||||
<summary><b>:hammer_and_wrench: Step-by-step guide for deploying on other platforms</b></summary>
|
||||
#### Use your own OAuth App
|
||||
|
||||
1. Fork or clone this repo as per your needs
|
||||
2. Move `express` from the devDependencies to the dependencies section of `package.json`
|
||||
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L54-L61>
|
||||
3. Run `npm i` if needed (initial setup)
|
||||
4. Run `node express.js` to start the server, or set the entry point to `express.js` in `package.json` if you're deploying on a managed service
|
||||
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L11>
|
||||
5. You're done 🎉
|
||||
</details>
|
||||
[Create your own OAuth App](https://github.com/settings/developers) and set the environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly.
|
||||
|
||||
#### Silence the turbo build warning
|
||||
|
||||
Set the environment variable `TURBO_PLATFORM_ENV_DISABLED` to `true` to disable the build-time warning from [turbo](https://turborepo.dev/) about environment variables missing from `turbo.json`. This warning is not relevant in our project.
|
||||
|
||||
### Available environment variables
|
||||
|
||||
@@ -204,4 +201,4 @@ See [the Vercel documentation](https://vercel.com/docs/concepts/projects/environ
|
||||
|
||||
You can keep your fork, and thus your private Vercel instance up to date with the upstream using GitHub's [Sync Fork button](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork). You can also use the [pull](https://github.com/wei/pull) package created by [@wei](https://github.com/wei) to automate this process.
|
||||
|
||||
As a prerequisite, GitHub has to know that your personal GitHub-Stats-Extended repo is a fork of https://github.com/stats-organization/github-stats-extended. This only works if you follow the "Step-by-step guide on setting up your own Vercel instance" above, instead of clicking the Vercel "Deploy" button above.
|
||||
As a prerequisite, GitHub has to know that your personal GitHub-Stats-Extended repo is a fork of https://github.com/stats-organization/github-stats-extended. This only works if you follow the "Step-by-step guide on setting up your own Vercel instance" above, instead of clicking the Vercel "Deploy" button above.
|
||||
|
||||
+66
-13
@@ -15,6 +15,7 @@ GitHub-Stats-Extended adds a frontend which allows users to visually configure s
|
||||
The frontend is based on [GitHub Trends](https://github.com/avgupta456/github-trends) by [@avgupta456](https://github.com/avgupta456).
|
||||
|
||||
### Aggregate stats across organizations
|
||||
|
||||
To include stars from repos which are not owned by you, but where you are a collaborator or organization member, add `&role=OWNER,ORGANIZATION_MEMBER,COLLABORATOR` to your stats card url. To include such repos in your language stats, you can also add the same parameter to your top languages card url.
|
||||
|
||||
See [here](advanced_documentation.md#filtering-by-repository-and-owner) for full feature documentation.
|
||||
@@ -25,15 +26,44 @@ The resolution of this most requested feature in github-readme-stats was [origin
|
||||
|
||||
GitHub-Stats-Extended proactively precomputes and caches cards. This solves the problem where [cards wouldn't load on the first try](https://github.com/anuraghazra/github-readme-stats/issues/2603). It also gives GitHub-Stats-Extended more time while generating cards in the background, which allows it to fetch more repo data:
|
||||
|
||||
### Customization of top languages card
|
||||
GitHub-Stats-Extended allows you to display your top languages without any numbers via the `hide_values` parameter. And the `prog_bar_bg_color` parameter allows you to customize the background color of the progress bars, e.g. by setting it to white:
|
||||
|
||||

|
||||
|
||||
### Multi-page fetching for accurate star counts
|
||||
|
||||
GitHub-Stats-Extended fetches up to 1000 of your starred repositories to accurately compute your stars count. In github-readme-stats, this is limited to 100 repos because github-readme-stats doesn't have the above-mentioned performance improvements.
|
||||
|
||||
### Light and dark mode in a single card URL
|
||||
|
||||
GitHub-Stats-Extended adds `theme_light`, `theme_dark`, and `*_light` / `*_dark` color parameter variants (e.g. `title_color_light`, `title_color_dark`) that let you embed both light and dark mode in a single card URL. The card will automatically switch between the two based on the viewer's browser or OS theme setting.
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
This approach works on any platform, not just GitHub - and on GitHub sponsorship pages, which don't support the other approaches to light/dark theming. See [Set light and dark mode in one card](advanced_documentation.md#set-light-and-dark-mode-in-one-card) for full documentation.
|
||||
|
||||
### GitHub-themed light and dark themes
|
||||
|
||||
GitHub-Stats-Extended adds `light_github` and `dark_github` [themes](../packages/core/src/themes/README.md) that exactly match GitHub's default light and dark UI colors. For repo and gist cards, use `light_github_repocard` and `dark_github_repocard` instead, which use a different icon color.
|
||||
|
||||
### New Contributions stat
|
||||
|
||||
GitHub-Stats-Extended adds an optional stat showing the total number of [contributions](https://docs.github.com/en/account-and-profile/reference/profile-contributions-reference#what-counts-as-a-contribution) (commits, pull requests, issues, etc.) across all years of a user's GitHub history. Add `&show=contributions` to your stats card URL to enable it. Whether private contributions are counted depends on [your GitHub profile visibility settings](https://docs.github.com/en/account-and-profile/how-tos/contribution-settings/manage-visibility-settings-for-private-contributions-and-achievements#changing-the-visibility-of-your-private-contributions).
|
||||
|
||||
Note that the pre-existing "Contributed to" stat indicates the number of repositories a user has contributed to, not the number of contributions.
|
||||
|
||||
### Customization of top languages card
|
||||
|
||||
GitHub-Stats-Extended allows you to display your top languages without any numbers via the `hide_values` parameter. And the `prog_bar_bg_color` parameter allows you to customize the background color of the progress bars, e.g. by setting it to white:
|
||||
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/top-langs?username=anuraghazra&langs_count=4&hide_values=true&prog_bar_bg_color=fff&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/top-langs?username=anuraghazra&langs_count=4&hide_values=true&prog_bar_bg_color=fff&theme=light_github" alt="anuraghazra's top languages without numbers" />
|
||||
</picture>
|
||||
|
||||
### Private contributions support
|
||||
|
||||
GitHub-Stats-Extended can include private contributions in your stats cards. You no longer have to deploy your own instance for that. Just log into the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) via the "GitHub Private Access" button (or click "Upgrade to Private Access" if already logged in). This will allow GitHub-Stats-Extended to see your private contributions.
|
||||
|
||||
### Display contributions to specific repositories or organizations
|
||||
@@ -44,23 +74,36 @@ Especially for regular contributors in open source projects it might make sense
|
||||
|
||||
See [here](advanced_documentation.md#filtering-by-repository-and-owner) for full feature documentation.
|
||||
|
||||
----------
|
||||
---
|
||||
|
||||
anuraghazra's contributions to github-readme-stats:
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&theme=dark_github_repocard"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&theme=light_github_repocard" alt="anuraghazra's contributions to github-readme-stats" />
|
||||
</picture>
|
||||
|
||||
Add `&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` to your repo card url to display your contributions to the pinned repository.
|
||||
|
||||
----------
|
||||
---
|
||||
|
||||
anurag's contributions to razorpay:
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&owner=razorpay&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=anurag%27s%20contributions%20to%20razorpay&card_width=333&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&owner=razorpay&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=anurag%27s%20contributions%20to%20razorpay&card_width=333&theme=light_github" alt="anurag's contributions to razorpay" />
|
||||
</picture>
|
||||
|
||||
Add `&repo=userA/repoA,orgB/repoB` or `&owner=userC,orgD` to your profile stats url to filter your contributions by repo or organization. (The screenshot above uses further customization options.)
|
||||
|
||||
### Other
|
||||
|
||||
GitHub-Stats-Extended adds various other, minor improvements. For example, the repo card now supports the `card_width` parameter.
|
||||
|
||||
## Why This Fork Exists
|
||||
@@ -68,16 +111,26 @@ GitHub-Stats-Extended adds various other, minor improvements. For example, the r
|
||||
[github-readme-stats](https://github.com/anuraghazra/github-readme-stats) is a great project, which unfortunately saw its development slow down in the past years, with [highly requested features](https://github.com/anuraghazra/github-readme-stats/issues/1935) getting delayed for a long time.
|
||||
|
||||
One of the valued maintainers [wrote](https://github.com/anuraghazra/github-readme-stats/pull/3911#issuecomment-3377726545):
|
||||
|
||||
> I joined the project as collaborator in the middle of 2023 and there was just a few guys in the team while hundreds of PRs, issues and discussions pending to be reviewed.
|
||||
>
|
||||
> The volume is overwhelming for the small team, especially taking into account that right now I'm alone online and working only sometimes when I have a free hours, so it took some time to get to your PR.
|
||||
|
||||
So I decided to implement some of the highly requested features and make the enhanced project available to everyone. Since the initial release of this fork I have been in contact with the maintainers of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) and we decided to join forces. github-stats-extended is now in the process of becoming the successor of github-readme-stats.
|
||||
So [@martin-mfg](https://github.com/martin-mfg) decided to fork the project, implement some of the highly requested features and make the enhanced project available to everyone. Since the initial release of this fork @martin-mfg joined forces with the maintainers of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) and GitHub-Stats-Extended is now becoming the successor of github-readme-stats.
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
If there should be any changes in github-readme-stats in the future, they will be synced into this fork.
|
||||
|
||||
GitHub-Stats-Extended aims to be fully compatible with [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). Generally, additional functionality introduced in this fork has to be explicitly enabled via some parameter.
|
||||
GitHub-Stats-Extended aims to be fully compatible with [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). Additional functionality introduced in this fork has to be explicitly enabled via some parameter.
|
||||
|
||||
So you can change an existing stats card url from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) to GitHub-Stats-Extended simply by changing the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`. The card will look the same.
|
||||
|
||||
There is only one exception to this: GitHub-Stats-Extended improves line wrapping for multi-line gist and repository descriptions.
|
||||
This should be an improvement for existing cards, but it still changes their appearance a bit.
|
||||
|
||||
Previously, line wrapping happened simply after 59 characters, with special handling for Chinese characters:
|
||||
|
||||
<img width="400" height="140" alt="character-based" src="https://github.com/user-attachments/assets/1cf7edba-7f6c-4a37-89d7-334cbe54f0f1" />
|
||||
|
||||
GitHub-Stats-Extended now takes the actual width of each character into account:
|
||||
|
||||
<img width="400" height="150" alt="new-server-side-calculation" src="https://github.com/user-attachments/assets/277b92df-b2a9-48be-bd2a-d28c1d6764c5" />
|
||||
|
||||
+15
-29
@@ -2,12 +2,14 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import eslintReact from "@eslint-react/eslint-plugin";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
|
||||
import {
|
||||
createTypeScriptImportResolver,
|
||||
defaultConditionNames,
|
||||
} from "eslint-import-resolver-typescript";
|
||||
import { importX } from "eslint-plugin-import-x";
|
||||
import { default as jsdoc } from "eslint-plugin-jsdoc";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
import { default as tseslint } from "typescript-eslint";
|
||||
|
||||
@@ -15,6 +17,10 @@ const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath, "Imported .gitignore patterns"),
|
||||
{
|
||||
name: "Generated GraphQL types",
|
||||
ignores: ["packages/core/src/graphql/generated/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
{
|
||||
@@ -26,14 +32,7 @@ export default defineConfig(
|
||||
/** Keep in sync with `tsconfig.base.json#customConditions` */
|
||||
"@stats/source",
|
||||
|
||||
"types",
|
||||
"import",
|
||||
|
||||
"require",
|
||||
"node",
|
||||
"node-addons",
|
||||
"browser",
|
||||
"default",
|
||||
...defaultConditionNames,
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -150,6 +149,10 @@ export default defineConfig(
|
||||
},
|
||||
],
|
||||
|
||||
// Keep `@param props.x` doc names in sync with the destructured
|
||||
// parameters so they cannot silently drift on rename.
|
||||
"jsdoc/check-param-names": "error",
|
||||
|
||||
// We don't need this we have typescript
|
||||
"jsdoc/require-returns": "off",
|
||||
"jsdoc/require-returns-description": "off",
|
||||
@@ -172,23 +175,6 @@ export default defineConfig(
|
||||
},
|
||||
{
|
||||
files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"],
|
||||
plugins: {
|
||||
react,
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/no-array-index-key": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
...eslintReact.configs["recommended-typescript"],
|
||||
},
|
||||
);
|
||||
|
||||
+8
-1
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"vitest": {
|
||||
"entry": ["tests/**/*.{test,test-d,bench}.{js,ts}"]
|
||||
},
|
||||
"workspaces": {
|
||||
"packages/core": {
|
||||
// generated by scripts/generate-graphql-types.js
|
||||
"ignore": ["src/graphql/generated/**"]
|
||||
},
|
||||
"apps/backend": {
|
||||
"entry": ["api-renamed/*.js", "express.js", "tests/bench/*.bench.js"]
|
||||
"entry": ["api-renamed/*.js", "express.js"]
|
||||
},
|
||||
"apps/frontend": {
|
||||
"entry": ["src/wakatime-override.ts"]
|
||||
|
||||
+17
-17
@@ -2,27 +2,26 @@
|
||||
"name": "@stats-organization/root",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "2.0.5",
|
||||
"@eslint-react/eslint-plugin": "5.17.1",
|
||||
"@eslint/compat": "2.1.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@types/node": "24.12.2",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@types/node": "24.13.3",
|
||||
"@vitest/coverage-v8": "catalog:default",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-jsdoc": "62.9.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.5.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-jsdoc": "63.1.0",
|
||||
"globals": "17.7.0",
|
||||
"husky": "9.1.7",
|
||||
"knip": "6.7.0",
|
||||
"lint-staged": "16.4.0",
|
||||
"prettier": "3.8.3",
|
||||
"turbo": "2.9.6",
|
||||
"knip": "6.27.0",
|
||||
"lint-staged": "17.0.8",
|
||||
"prettier": "3.9.5",
|
||||
"turbo": "2.10.5",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.0",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -38,7 +37,8 @@
|
||||
"lint:eslint": "eslint",
|
||||
"lint:eslint:fix": "eslint --fix",
|
||||
"lint:knip": "knip",
|
||||
"typecheck": "turbo run typecheck"
|
||||
"lint:deps": "node scripts/assert-deduped.ts",
|
||||
"typecheck": "turbo run typecheck && tsc -p tsconfig.scripts.json"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,css,json,jsonc,yaml,yml}": "prettier --write"
|
||||
|
||||
@@ -1 +1 @@
|
||||
Core library powering [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) and [GitHub Readme Stats Action](https://github.com/readme-tools/github-readme-stats-action).
|
||||
Core library powering [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) and [GitHub Readme Stats Action](https://github.com/stats-organization/github-readme-stats-action).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stats-organization/github-readme-stats-core",
|
||||
"version": "0.0.1",
|
||||
"version": "2.1.5",
|
||||
"type": "module",
|
||||
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
||||
"bugs": {
|
||||
@@ -75,20 +75,27 @@
|
||||
"bench": "vitest bench --run --config vitest.config.bench.ts",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json",
|
||||
"theme-readme-gen": "node scripts/generate-theme-doc",
|
||||
"generate-langs-json": "node scripts/generate-langs-json"
|
||||
"generate-graphql-types": "node scripts/generate-graphql-types",
|
||||
"check-graphql-types": "node scripts/generate-graphql-types --check",
|
||||
"generate-theme-readme": "node scripts/generate-theme-readme",
|
||||
"generate-language-colors": "node scripts/generate-language-colors"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/core": "6.2.0",
|
||||
"@graphql-codegen/typescript": "6.1.0",
|
||||
"@graphql-codegen/typescript-operations": "6.1.2",
|
||||
"@octokit/graphql-schema": "15.26.1",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@uppercod/css-to-object": "1.1.1",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsdom": "29.0.2",
|
||||
"graphql": "16.11.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"jsdom": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"axios": "catalog:default",
|
||||
"emoji-name-map": "^2.0.3",
|
||||
"github-username-regex": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Generates TypeScript types for `src/graphql/queries` from GitHub's published schema:
|
||||
* one file per query, plus a `common.ts` for the types their variables name.
|
||||
*
|
||||
* Pass `--check` to fail on drift instead of writing.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { codegen } from "@graphql-codegen/core";
|
||||
import * as typescriptPlugin from "@graphql-codegen/typescript";
|
||||
import * as typescriptOperationsPlugin from "@graphql-codegen/typescript-operations";
|
||||
import { schema as githubSchema } from "@octokit/graphql-schema";
|
||||
import {
|
||||
GraphQLSchema,
|
||||
Kind,
|
||||
buildSchema,
|
||||
getNamedType,
|
||||
isSpecifiedScalarType,
|
||||
parse,
|
||||
print,
|
||||
printSchema,
|
||||
typeFromAST,
|
||||
visit,
|
||||
} from "graphql";
|
||||
import { format, resolveConfig } from "prettier";
|
||||
|
||||
/**
|
||||
* @typedef {Parameters<typeof codegen>[0]} CodegenOptions
|
||||
* @typedef {CodegenOptions["pluginMap"][string]} CodegenPlugin
|
||||
* @typedef {import("@graphql-codegen/typescript").TypeScriptPluginConfig} TypeScriptPluginConfig
|
||||
* @typedef {import("@graphql-codegen/typescript-operations").TypeScriptDocumentsPluginConfig} TypeScriptDocumentsPluginConfig
|
||||
* @typedef {import("graphql").ASTNode} ASTNode
|
||||
* @typedef {import("graphql").FragmentDefinitionNode} FragmentDefinitionNode
|
||||
* @typedef {import("graphql").GraphQLNamedType} GraphQLNamedType
|
||||
* @typedef {import("graphql").OperationDefinitionNode} OperationDefinitionNode
|
||||
* @typedef {Map<string, FragmentDefinitionNode>} FragmentMap Fragment definitions by name.
|
||||
*/
|
||||
|
||||
const PACKAGE_ROOT = path.join(import.meta.dirname, "..");
|
||||
const QUERIES_DIR = path.join(PACKAGE_ROOT, "src/graphql/queries");
|
||||
const OUT_DIR = path.join(PACKAGE_ROOT, "src/graphql/generated");
|
||||
const COMMON_FILE = path.join(OUT_DIR, "common.ts");
|
||||
// `typescript-operations` resolves this against the working directory
|
||||
const COMMON_IMPORT_PATH = path.relative(
|
||||
process.cwd(),
|
||||
COMMON_FILE.replace(/\.ts$/, ".js"),
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {string} file Absolute path.
|
||||
* @returns {string} The same path relative to the package, for log messages.
|
||||
*/
|
||||
const pathRelativeFromRoot = (file) => path.relative(PACKAGE_ROOT, file);
|
||||
|
||||
// CI mode: regenerate in memory, fail on any difference
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
/** @type {TypeScriptPluginConfig & TypeScriptDocumentsPluginConfig} */
|
||||
const config = {
|
||||
emitLegacyCommonJSImports: false,
|
||||
enumsAsTypes: true,
|
||||
skipTypename: true,
|
||||
useTypeImports: true,
|
||||
// enums and input types come from the common file, imported only where used
|
||||
importSchemaTypesFrom: COMMON_IMPORT_PATH,
|
||||
scalars: { DateTime: "string" },
|
||||
};
|
||||
|
||||
// GitHub's SDL declares a few fields twice, which trips SDL validation
|
||||
const schemaAst = buildSchema(githubSchema.idl, { assumeValidSDL: true });
|
||||
// printed once: every query output reuses it
|
||||
const schemaDocument = parse(printSchema(schemaAst));
|
||||
|
||||
// a missing folder is a broken checkout, not an empty query set: generating from it would
|
||||
// delete every existing output
|
||||
const queryDir = await fs.readdir(QUERIES_DIR).catch(() => {
|
||||
console.error(`No queries in ${pathRelativeFromRoot(QUERIES_DIR)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
const queryFiles = queryDir.filter((file) => file.endsWith(".graphql")).sort();
|
||||
|
||||
const documents = await Promise.all(
|
||||
queryFiles.map(async (file) => {
|
||||
const location = path.join(QUERIES_DIR, file);
|
||||
const output = path.join(OUT_DIR, `${path.basename(file, ".graphql")}.ts`);
|
||||
const document = parse(await fs.readFile(location, "utf8"));
|
||||
return { location, output, document };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Every fragment a node spreads, directly or through another fragment.
|
||||
*
|
||||
* @param {ASTNode} node Operation or fragment definition to walk.
|
||||
* @param {FragmentMap} fragments Fragments defined in the same file.
|
||||
* @param {FragmentMap} found Fragments collected so far.
|
||||
* @returns {FragmentMap} The fragment definitions the node needs.
|
||||
*/
|
||||
const spreadFragments = (node, fragments, found = new Map()) => {
|
||||
visit(node, {
|
||||
FragmentSpread(spread) {
|
||||
const name = spread.name.value;
|
||||
if (found.has(name)) {
|
||||
return;
|
||||
}
|
||||
const fragment = fragments.get(name);
|
||||
if (!fragment) {
|
||||
throw new Error(`No definition found for fragment "${name}"`);
|
||||
}
|
||||
found.set(name, fragment);
|
||||
spreadFragments(fragment, fragments, found);
|
||||
},
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} value Word to capitalize.
|
||||
* @returns {string} The capitalized word.
|
||||
*/
|
||||
const pascalCase = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||
|
||||
/**
|
||||
* The names `typescript-operations` gives an operation's types.
|
||||
*
|
||||
* @param {OperationDefinitionNode} operation Operation definition.
|
||||
* @returns {{ documentName: string; resultType: string; variablesType: string }} Names to reference in the emitted document.
|
||||
*/
|
||||
const operationNames = (operation) => {
|
||||
if (!operation.name) {
|
||||
throw new Error("Every query needs a name to generate types from");
|
||||
}
|
||||
const name = pascalCase(operation.name.value);
|
||||
// `typescript-operations` appends the operation type unconditionally
|
||||
const resultType = `${name}${pascalCase(operation.operation)}`;
|
||||
return {
|
||||
documentName: `${name}Document`,
|
||||
resultType,
|
||||
variablesType: `${resultType}Variables`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Emits one typed document per operation, beside its types.
|
||||
*
|
||||
* @type {CodegenPlugin['plugin']}
|
||||
*/
|
||||
const documentsPlugin = (_schema, files) => {
|
||||
// `document` is optional on codegen's file type; ours are always parsed
|
||||
const definitions = files.flatMap((file) => file.document?.definitions ?? []);
|
||||
const { FRAGMENT_DEFINITION, OPERATION_DEFINITION } = Kind;
|
||||
|
||||
const fragments = new Map(
|
||||
definitions
|
||||
.filter((definition) => definition.kind === FRAGMENT_DEFINITION)
|
||||
.map((definition) => [definition.name.value, definition]),
|
||||
);
|
||||
|
||||
const operations = definitions
|
||||
.filter((definition) => definition.kind === OPERATION_DEFINITION)
|
||||
.map((operation) => {
|
||||
const { documentName, resultType, variablesType } =
|
||||
operationNames(operation);
|
||||
const text = [
|
||||
print(operation),
|
||||
...[...spreadFragments(operation, fragments).values()].map(print),
|
||||
].join("\n");
|
||||
return `export const ${documentName} = graphqlDocument<${resultType}, ${variablesType}>(\`\n${text}\`);`;
|
||||
});
|
||||
|
||||
return {
|
||||
prepend: [`import { graphqlDocument } from "../graphqlDocument.js";`],
|
||||
content: operations.join("\n\n"),
|
||||
};
|
||||
};
|
||||
|
||||
// only the types the queries name in their variables; an unknown name yields undefined,
|
||||
// which codegen reports later against the query
|
||||
/** @type {Set<GraphQLNamedType>} */
|
||||
const commonTypes = new Set();
|
||||
for (const { document } of documents) {
|
||||
visit(document, {
|
||||
VariableDefinition({ type }) {
|
||||
// `getNamedType` strips the `[…]!` wrappers the variable declares
|
||||
const named = getNamedType(typeFromAST(schemaAst, type));
|
||||
if (named && !isSpecifiedScalarType(named)) {
|
||||
commonTypes.add(named);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// emitted for the fragment masking we don't generate, with no config to leave it out
|
||||
const INCREMENTAL_TYPE =
|
||||
/^\/\*\* Internal type\. DO NOT USE DIRECTLY\. \*\/\nexport type Incremental<T> = [^\n]*\n/m;
|
||||
|
||||
const BANNER = `// Generated file — see .github/CONTRIBUTING.md\n\n`;
|
||||
|
||||
/** @type {Array<Omit<CodegenOptions, "config">>} */
|
||||
const outputs = documents.map((file) => ({
|
||||
filename: file.output,
|
||||
schema: schemaDocument,
|
||||
schemaAst,
|
||||
documents: [file],
|
||||
plugins: [{ "typescript-operations": {} }, { documents: {} }],
|
||||
pluginMap: {
|
||||
"typescript-operations": typescriptOperationsPlugin,
|
||||
documents: { plugin: documentsPlugin },
|
||||
},
|
||||
}));
|
||||
|
||||
// queries with only built-in scalar variables need no common file
|
||||
if (commonTypes.size) {
|
||||
const commonSchema = new GraphQLSchema({ types: [...commonTypes] });
|
||||
outputs.push({
|
||||
filename: COMMON_FILE,
|
||||
schema: parse(printSchema(commonSchema)),
|
||||
schemaAst: commonSchema,
|
||||
documents: [],
|
||||
plugins: [{ typescript: {} }],
|
||||
pluginMap: { typescript: typescriptPlugin },
|
||||
});
|
||||
}
|
||||
|
||||
const prettierConfig = await resolveConfig(COMMON_FILE);
|
||||
const generated = await Promise.all(
|
||||
outputs.map(async ({ filename, ...options }) => {
|
||||
const content = await codegen({ filename, config, ...options });
|
||||
return {
|
||||
filename,
|
||||
content: await format(BANNER + content.replace(INCREMENTAL_TYPE, ""), {
|
||||
...prettierConfig,
|
||||
parser: "typescript",
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const queryCount = `${documents.length} ${documents.length === 1 ? "query" : "queries"}`;
|
||||
|
||||
// a deleted query leaves its generated file behind; anything else in the folder is not ours
|
||||
const expected = new Set(generated.map(({ filename }) => filename));
|
||||
const stale = (await fs.readdir(OUT_DIR).catch(() => []))
|
||||
.filter((file) => file.endsWith(".ts"))
|
||||
.map((file) => path.join(OUT_DIR, file))
|
||||
.filter((file) => !expected.has(file));
|
||||
|
||||
if (checkOnly) {
|
||||
const outdated = stale.map(pathRelativeFromRoot);
|
||||
for (const { filename, content } of generated) {
|
||||
const current = await fs.readFile(filename, "utf8").catch(() => null);
|
||||
if (current !== content) {
|
||||
outdated.push(pathRelativeFromRoot(filename));
|
||||
}
|
||||
}
|
||||
|
||||
if (outdated.length > 0) {
|
||||
const files = outdated.map((file) => ` ${file}`).join("\n");
|
||||
const message = `GraphQL types are out of date:\n${files}\n\nRun \`pnpm --filter ./packages/core/ run generate-graphql-types\`.`;
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const message = `GraphQL types are up to date (${queryCount})`;
|
||||
console.log(message);
|
||||
} else {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
for (const { filename, content } of generated) {
|
||||
await fs.writeFile(filename, content);
|
||||
}
|
||||
|
||||
for (const file of stale) {
|
||||
await fs.rm(file);
|
||||
const removed = `Removed ${pathRelativeFromRoot(file)} — no query generates it`;
|
||||
console.log(removed);
|
||||
}
|
||||
|
||||
const outDir = pathRelativeFromRoot(OUT_DIR);
|
||||
const message = `Generated ${generated.length} files in ${outDir} from ${queryCount}`;
|
||||
console.log(message);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import fs from "fs";
|
||||
|
||||
import axios from "axios";
|
||||
import { default as jsYaml } from "js-yaml";
|
||||
import * as jsYaml from "js-yaml";
|
||||
import * as prettier from "prettier";
|
||||
|
||||
const LANGS_FILEPATH = "./src/common/languageColors.json";
|
||||
+29
-5
@@ -1,6 +1,8 @@
|
||||
import fs from "fs";
|
||||
|
||||
import { themes } from "../src/themes/index.js";
|
||||
import { format, resolveConfig } from "prettier";
|
||||
|
||||
import { themes } from "../src/themes/index.ts";
|
||||
|
||||
const TARGET_FILE = "./src/themes/README.md";
|
||||
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
|
||||
@@ -62,11 +64,25 @@ const createTableItem = ({ link, label, isRepoCard }) => {
|
||||
return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`;
|
||||
};
|
||||
|
||||
const allThemeNames = Object.keys(themes);
|
||||
|
||||
// Some themes come in pairs: `X_repocard` for the repo and gist cards, `X` for
|
||||
// the stats, top languages and WakaTime cards. Each table lists only its own
|
||||
// variant.
|
||||
//
|
||||
// Keep in sync with the theme lists in `apps/frontend/src/pages/Home/stages/Theme.tsx`,
|
||||
// which splits the picker by the same rule.
|
||||
const repoCardThemes = allThemeNames.filter(
|
||||
(name) => !allThemeNames.includes(`${name}_repocard`),
|
||||
);
|
||||
|
||||
const nonRepoCardThemes = allThemeNames.filter(
|
||||
(name) => !name.endsWith("_repocard"),
|
||||
);
|
||||
|
||||
const generateTable = ({ isRepoCard }) => {
|
||||
const rows = [];
|
||||
const themesFiltered = Object.keys(themes).filter(
|
||||
(name) => name !== (isRepoCard ? "default" : "default_repocard"),
|
||||
);
|
||||
const themesFiltered = isRepoCard ? repoCardThemes : nonRepoCardThemes;
|
||||
|
||||
for (let i = 0; i < themesFiltered.length; i += 3) {
|
||||
const one = themesFiltered[i];
|
||||
@@ -103,4 +119,12 @@ const buildReadme = () => {
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
fs.writeFileSync(TARGET_FILE, buildReadme());
|
||||
const prettierConfig = await resolveConfig(TARGET_FILE);
|
||||
|
||||
fs.writeFileSync(
|
||||
TARGET_FILE,
|
||||
await format(buildReadme(), {
|
||||
...prettierConfig,
|
||||
parser: "markdown",
|
||||
}),
|
||||
);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* `emoji-name-map` ships no type definitions and exposes no `exports` map, so it
|
||||
* cannot be resolved under `nodenext`. Declare the minimal surface used here.
|
||||
*/
|
||||
declare module "emoji-name-map" {
|
||||
const emojiNameMap: {
|
||||
get(name: string): string | undefined;
|
||||
};
|
||||
export default emojiNameMap;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module "github-username-regex" {
|
||||
const githubUsernameRegex: RegExp;
|
||||
export default githubUsernameRegex;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderGistCard } from "../cards/gist.js";
|
||||
import { findInvalidColorParam, pickColorParams } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -14,33 +13,47 @@ import { isLocaleAvailable } from "../translations.js";
|
||||
export default async (
|
||||
{
|
||||
id,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner,
|
||||
browser_rendering,
|
||||
hide_border,
|
||||
...remainingParams
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
const colorParams = pickColorParams(remainingParams);
|
||||
|
||||
const invalidColorInput = findInvalidColorParam(colorParams);
|
||||
if (invalidColorInput) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
renderOptions: colorParams,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w/.,]+$/;
|
||||
if (id && !safePattern.test(id)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Gist ID contains unsafe characters",
|
||||
renderOptions: colorParams,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -51,13 +64,8 @@ export default async (
|
||||
return {
|
||||
status: "success",
|
||||
content: renderGistCard(gistData, {
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
...colorParams,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
show_owner: parseBoolean(show_owner),
|
||||
browser_rendering: parseBoolean(browser_rendering),
|
||||
@@ -72,11 +80,7 @@ export default async (
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
...colorParams,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
@@ -86,13 +90,7 @@ export default async (
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
renderOptions: colorParams,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderStatsCard } from "../cards/stats.js";
|
||||
import { findInvalidColorParam, pickColorParams } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -25,13 +24,7 @@ export default async (
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
line_height,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold,
|
||||
bg_color,
|
||||
theme,
|
||||
exclude_repo,
|
||||
custom_title,
|
||||
locale,
|
||||
@@ -40,25 +33,32 @@ export default async (
|
||||
number_format,
|
||||
role,
|
||||
number_precision,
|
||||
border_color,
|
||||
rank_icon,
|
||||
show,
|
||||
...remainingParams
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
const colorParams = pickColorParams(remainingParams);
|
||||
|
||||
const invalidColorInput = findInvalidColorParam(colorParams);
|
||||
if (invalidColorInput) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
renderOptions: colorParams,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -75,13 +75,7 @@ export default async (
|
||||
message: "Something went wrong",
|
||||
secondaryMessage:
|
||||
"Username, repository or owner contains unsafe characters",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
renderOptions: colorParams,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -119,6 +113,7 @@ export default async (
|
||||
content: renderStatsCard(
|
||||
stats,
|
||||
{
|
||||
...colorParams,
|
||||
hide: parseArray(hide),
|
||||
show_icons: parseBoolean(show_icons),
|
||||
hide_title: parseBoolean(hide_title),
|
||||
@@ -128,16 +123,9 @@ export default async (
|
||||
include_all_commits: parseBoolean(include_all_commits),
|
||||
commits_year: parseInt(commits_year, 10),
|
||||
line_height,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold: parseBoolean(text_bold),
|
||||
bg_color,
|
||||
theme,
|
||||
custom_title,
|
||||
border_radius,
|
||||
border_color,
|
||||
number_format,
|
||||
number_precision: parseInt(number_precision, 10),
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
@@ -158,11 +146,7 @@ export default async (
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
...colorParams,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
@@ -172,13 +156,7 @@ export default async (
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
renderOptions: colorParams,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user