Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84835caea1 | ||
|
|
dc9c926800 | ||
|
|
986eaad1a8 | ||
|
|
5403eefbd3 | ||
|
|
12b27566be | ||
|
|
40097713b9 | ||
|
|
2168c1f91e | ||
|
|
fa82216dfe | ||
|
|
9cf716c85f | ||
|
|
ef3f207dbf | ||
|
|
35fbec8004 | ||
|
|
3ef27f872c | ||
|
|
9f4ad921aa | ||
|
|
5557e9c772 | ||
|
|
570fc4aaed | ||
|
|
b6f4183ddd | ||
|
|
2ac454a177 | ||
|
|
b837c50885 | ||
|
|
73189400ee | ||
|
|
8a239313d3 | ||
|
|
a3dbb37dd2 | ||
|
|
9c4e631bc7 | ||
|
|
6ebd9a082f | ||
|
|
9236acd99d | ||
|
|
12b4e979a8 | ||
|
|
5c4ea2a797 | ||
|
|
7d666e2510 | ||
|
|
96a18562f0 | ||
|
|
b64d601d47 | ||
|
|
26aec2a4af | ||
|
|
ad23ab39ed | ||
|
|
d982bef3c5 | ||
|
|
2463f0900d | ||
|
|
23ba727b9a | ||
|
|
6c625fb76b | ||
|
|
0aafbbbdb5 | ||
|
|
8e6e0fac47 |
@@ -14,11 +14,11 @@ runs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||||
|
|
||||||
- name: Setup Node.js (via input)
|
- name: Setup Node.js (via input)
|
||||||
if: ${{ inputs.node-version }}
|
if: ${{ inputs.node-version }}
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: ${{ inputs.node-version }}
|
node-version: ${{ inputs.node-version }}
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -26,7 +26,7 @@ runs:
|
|||||||
|
|
||||||
- name: Setup Node.js (via .nvmrc)
|
- name: Setup Node.js (via .nvmrc)
|
||||||
if: ${{ !inputs.node-version }}
|
if: ${{ !inputs.node-version }}
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
@@ -67,8 +67,10 @@ jobs:
|
|||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: pnpm run 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
|
- name: Install Playwright Browsers
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: pnpm --filter ./apps/frontend/ run test:e2e
|
run: pnpm --filter ./apps/frontend/ run test:e2e
|
||||||
@@ -85,7 +87,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
@@ -106,7 +108,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ on:
|
|||||||
# │ │ │ │ │
|
# │ │ │ │ │
|
||||||
# │ │ │ │ │
|
# │ │ │ │ │
|
||||||
# * * * * *
|
# * * * * *
|
||||||
- cron: "45 * * * *"
|
- cron: "15,45 * * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
triggerRepeatRecent:
|
triggerRepeatRecent:
|
||||||
if: |
|
if: |
|
||||||
@@ -24,7 +26,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Make Request
|
- name: Make Request
|
||||||
id: myRequest
|
id: myRequest
|
||||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
uses: fjogeleit/http-request-action@f98bb26551cd1af7d3eb2674578a80754ece88db # v2
|
||||||
with:
|
with:
|
||||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||||
method: "POST"
|
method: "POST"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
|
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"yzhang.markdown-all-in-one",
|
"yzhang.markdown-all-in-one",
|
||||||
"prettier.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"github.vscode-github-actions"
|
"github.vscode-github-actions"
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+1
-2
@@ -3,8 +3,7 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.tabSize": 2,
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"cSpell.words": ["Wakatime"]
|
"cSpell.words": ["retryer", "Wakatime"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiration": 39600,
|
"expiration": 255600,
|
||||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||||
"passQuery": true
|
"passQuery": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiration": 39600,
|
"expiration": 255600,
|
||||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||||
"passQuery": true
|
"passQuery": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiration": 39600,
|
"expiration": 255600,
|
||||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||||
"passQuery": true
|
"passQuery": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiration": 39600,
|
"expiration": 255600,
|
||||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||||
"passQuery": true
|
"passQuery": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiration": 39600,
|
"expiration": 255600,
|
||||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||||
"passQuery": true
|
"passQuery": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
"vitest": "catalog:default"
|
"vitest": "catalog:default"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "catalog:default",
|
|
||||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||||
"pg": "^8.21.0"
|
"axios": "catalog:default",
|
||||||
|
"pg": "^8.22.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,6 @@ export async function repeatRecentRequests() {
|
|||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
console.log("No recent requests found.");
|
console.log("No recent requests found.");
|
||||||
} else {
|
} else {
|
||||||
await makeRequests(urls, 5);
|
await makeRequests(urls, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const STATS_MOCK_RESPONSE = {
|
|||||||
followers: { totalCount: 0 },
|
followers: { totalCount: 0 },
|
||||||
repositories: {
|
repositories: {
|
||||||
totalCount: 1,
|
totalCount: 1,
|
||||||
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
|
nodes: [{ name: REPO, stargazerCount: 1 }],
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
endCursor: "cursor",
|
endCursor: "cursor",
|
||||||
@@ -110,9 +110,7 @@ const REPO_MOCK_RESPONSE = {
|
|||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTemplate: false,
|
isTemplate: false,
|
||||||
stargazers: {
|
stargazerCount: 1,
|
||||||
totalCount: 1,
|
|
||||||
},
|
|
||||||
description: "Simple cra test repo.",
|
description: "Simple cra test repo.",
|
||||||
primaryLanguage: {
|
primaryLanguage: {
|
||||||
color: "#f1e05a",
|
color: "#f1e05a",
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ exports[`Test /api contract > should match the public many-params response snaps
|
|||||||
|
|
||||||
</g>
|
</g>
|
||||||
</svg>",
|
</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":"\\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 stargazerCount\\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"]}}",
|
||||||
"headers": [
|
"headers": [
|
||||||
[
|
[
|
||||||
"Cache-Control",
|
"Cache-Control",
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ exports[`Test /api/pin contract > should match the public many-params response s
|
|||||||
|
|
||||||
</g>
|
</g>
|
||||||
</svg>",
|
</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":"\\n fragment 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 }\\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"}}",
|
||||||
"headers": [
|
"headers": [
|
||||||
[
|
[
|
||||||
"Cache-Control",
|
"Cache-Control",
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const data_stats = {
|
|||||||
repositories: {
|
repositories: {
|
||||||
totalCount: 3,
|
totalCount: 3,
|
||||||
nodes: [
|
nodes: [
|
||||||
{ name: "repo-keep-1", stargazers: { totalCount: 1500 } },
|
{ name: "repo-keep-1", stargazerCount: 1500 },
|
||||||
{ name: "repo-exclude-me", stargazers: { totalCount: 9999 } },
|
{ name: "repo-exclude-me", stargazerCount: 9999 },
|
||||||
{ name: "repo-keep-2", stargazers: { totalCount: 2600 } },
|
{ name: "repo-keep-2", stargazerCount: 2600 },
|
||||||
],
|
],
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
@@ -73,9 +73,7 @@ export const data_user = {
|
|||||||
username: "anuraghazra",
|
username: "anuraghazra",
|
||||||
name: "convoychat",
|
name: "convoychat",
|
||||||
nameWithOwner: "anuraghazra/convoychat",
|
nameWithOwner: "anuraghazra/convoychat",
|
||||||
stargazers: {
|
stargazerCount: 38000,
|
||||||
totalCount: 38000,
|
|
||||||
},
|
|
||||||
description:
|
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.",
|
"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: {
|
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
|
// Logo exists
|
||||||
await expect(page.locator('img[alt="logo"]')).toBeVisible();
|
await expect(page.locator('img[alt="logo"]')).toBeVisible();
|
||||||
|
|
||||||
// Star on GitHub button
|
// Star on GitHub link
|
||||||
const starButton = page.getByRole("button", { name: /star on/i });
|
const starButton = page.getByRole("link", { name: /star on/i });
|
||||||
await expect(starButton).toBeVisible();
|
await expect(starButton).toBeVisible();
|
||||||
|
|
||||||
const githubLink = page.locator(
|
const githubLink = page.locator(
|
||||||
@@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => {
|
|||||||
await expect(guestBtn).toBeEnabled();
|
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 }) => {
|
test("navigates between steps", async ({ page }) => {
|
||||||
await page.goto("");
|
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>
|
<title>GitHub Stats Extended</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
|||||||
@@ -14,29 +14,29 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
|
||||||
"axios": "catalog:default",
|
|
||||||
"axios-cache-interceptor": "^1",
|
|
||||||
"daisyui": "^5.5.23",
|
|
||||||
"@stats-organization/github-readme-stats-backend": "workspace:^",
|
"@stats-organization/github-readme-stats-backend": "workspace:^",
|
||||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"axios": "catalog:default",
|
||||||
|
"axios-cache-interceptor": "^1.12.0",
|
||||||
|
"daisyui": "^5.6.18",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-loading-skeleton": "^3.3.1",
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-spinners": "^0.17.0",
|
"react-spinners": "^0.17.0",
|
||||||
"react-toastify": "^11.1.0",
|
"react-toastify": "^11.1.0",
|
||||||
"redux": "^5.0.1",
|
"redux": "^5.0.1",
|
||||||
"save-svg-as-png": "^1.4.17",
|
"save-svg-as-png": "^1.4.17",
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.3",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.3",
|
||||||
"vite": "catalog:default",
|
"vite": "catalog:default",
|
||||||
"vitest": "catalog:default"
|
"vitest": "catalog:default"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,39 +1,76 @@
|
|||||||
import { clsx } from "clsx";
|
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 { CardImage } from "./CardImage";
|
||||||
|
import { LIGHT_CARD_BG } from "./themeBackdrop";
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
imageSrc: string;
|
card: CardUrlBuilder;
|
||||||
stage: number;
|
stage: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
fixedSize?: 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 = ({
|
export const Card = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
imageSrc,
|
card,
|
||||||
stage,
|
stage,
|
||||||
selected = false,
|
selected = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
fixedSize = false,
|
fixedSize = false,
|
||||||
|
backgroundColor,
|
||||||
|
titleColor,
|
||||||
}: CardProps): JSX.Element => {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("p-6 rounded border-2", {
|
className={clsx("p-6 rounded border-4", {
|
||||||
"h-[370px] w-[510px]": fixedSize,
|
"h-[370px] w-[510px]": fixedSize,
|
||||||
"border-blue-500 bg-blue-50": selected,
|
"border-primary": selected,
|
||||||
"border-gray-200 bg-white hover:bg-gray-50": !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>
|
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
|
||||||
<CardImage
|
<CardImage
|
||||||
imageSrc={imageSrc}
|
card={card}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
className={clsx({ "flex justify-center": fixedSize })}
|
className={clsx({ "flex justify-center": fixedSize })}
|
||||||
stage={stage}
|
stage={stage}
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
import { HOST } from "../../constants";
|
import { HOST } from "../../constants";
|
||||||
|
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||||
|
|
||||||
import { SvgInline } from "./SvgInline";
|
import { SvgInline } from "./SvgInline";
|
||||||
|
|
||||||
interface CardImageProps {
|
interface CardImageProps {
|
||||||
imageSrc: string;
|
card: CardUrlBuilder;
|
||||||
stage: number;
|
stage: number;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardImage = ({
|
export const CardImage = ({
|
||||||
imageSrc,
|
card,
|
||||||
stage,
|
stage,
|
||||||
compact = false,
|
compact = false,
|
||||||
className,
|
className,
|
||||||
}: CardImageProps) => {
|
}: 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 (
|
return (
|
||||||
<div className={clsx("w-full relative", className)}>
|
<div className={clsx("w-full relative", className)}>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
|
|||||||
FETCH_MULTI_PAGE_STARS: "10",
|
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
|
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);
|
loadConfigFromEnv(config);
|
||||||
|
|
||||||
setLoaded(false);
|
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 { clsx } from "clsx";
|
||||||
import type { HTMLProps, JSX, ReactNode } from "react";
|
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;
|
children: ReactNode;
|
||||||
|
/** DaisyUI color variant. Omit for the default neutral button. */
|
||||||
|
variant?: ButtonVariant | undefined;
|
||||||
|
size?: ButtonSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button(props: ButtonProps): JSX.Element {
|
export function Button(props: ButtonProps): JSX.Element {
|
||||||
const { className, children, ...rest } = props;
|
const { className, children, variant, size = "md", ...rest } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...rest}
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function Checkbox({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
className="checkbox bg-white text-[#18181b] mr-2"
|
className="checkbox checkbox-primary mr-2"
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
onCheckedChange(!checked);
|
onCheckedChange(!checked);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ export function Select({
|
|||||||
}: SelectProps): JSX.Element {
|
}: SelectProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={clsx(
|
className={clsx("text-base select select-sm w-40 mt-4", className)}
|
||||||
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
value={selectedOption.value}
|
value={selectedOption.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onOptionChange(options[e.target.selectedIndex] as SelectOption);
|
onOptionChange(options[e.target.selectedIndex] as SelectOption);
|
||||||
@@ -43,7 +40,7 @@ export function Select({
|
|||||||
value={option.value}
|
value={option.value}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
className={clsx({
|
className={clsx({
|
||||||
"bg-blue-200": option.value === selectedOption.value,
|
"bg-primary/20": option.value === selectedOption.value,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{option.label}
|
{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,
|
id: 6,
|
||||||
label: "Only Languages",
|
label: "Only Languages",
|
||||||
disabled: false,
|
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 type { JSX, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useDebouncedField } from "../../hooks/useDebouncedField";
|
||||||
|
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
|
|
||||||
interface NumericSectionProps {
|
interface NumericSectionProps {
|
||||||
@@ -26,45 +27,21 @@ export function NumericSection({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: NumericSectionProps): JSX.Element {
|
}: NumericSectionProps): JSX.Element {
|
||||||
const [internalValue, setInternalValue] = useState(() => value?.toString());
|
const { inputValue, setInputValue } = useDebouncedField({
|
||||||
const debounceTimeout = useRef<number | null>(null);
|
value,
|
||||||
|
onValueChange,
|
||||||
useEffect(() => {
|
type: "number",
|
||||||
// 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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={title}>
|
<Section title={title}>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="border border-gray-300 bg-white rounded px-2 py-1 mt-2 w-1/4"
|
className="border border-base-content/20 rounded px-2 py-1 mt-2 w-1/4 bg-base-100"
|
||||||
value={internalValue ?? ""}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInternalValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function ProgressSection({
|
|||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
|
"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",
|
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
|
||||||
)}
|
)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -32,14 +32,16 @@ function ProgressSection({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx("text-lg font-bold", {
|
className={clsx("text-lg font-bold", {
|
||||||
"text-blue-500": passed,
|
"text-primary": passed,
|
||||||
"text-gray-500": !passed,
|
"text-base-content/60": !passed,
|
||||||
"-mt-[4px]": isActive,
|
"-mt-[4px]": isActive,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{`Step ${num + 1}`}
|
{`Step ${num + 1}`}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,16 +61,23 @@ export function ProgressBar({
|
|||||||
const rightDisabled = currItemIndex === items.length - 1;
|
const rightDisabled = currItemIndex === items.length - 1;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<LeftArrowIcon
|
<button
|
||||||
className={clsx("w-8 h-8", {
|
type="button"
|
||||||
"text-gray-400 cursor-not-allowed": leftDisabled,
|
aria-label="Previous step"
|
||||||
"text-gray-700 cursor-pointer": !leftDisabled,
|
disabled={leftDisabled}
|
||||||
})}
|
className="btn btn-ghost btn-circle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onItemClick(Math.max(currItemIndex - 1, 0));
|
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">
|
<div className="px-2 flex-grow flex flex-row">
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
@@ -85,15 +94,22 @@ export function ProgressBar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<RightArrowIcon
|
<button
|
||||||
className={clsx("w-8 h-8", {
|
type="button"
|
||||||
"text-gray-400 cursor-not-allowed": rightDisabled,
|
aria-label="Next step"
|
||||||
"text-gray-700 cursor-pointer": !rightDisabled,
|
disabled={rightDisabled}
|
||||||
})}
|
className="btn btn-ghost btn-circle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onItemClick(Math.min(currItemIndex + 1, items.length - 1));
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ export function Section({ title, children }: SectionProps): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className="flex relative pb-12">
|
<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-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>
|
||||||
<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" />
|
<LightningIcon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow pl-4">
|
<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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const options: Array<SelectOption> = [
|
|||||||
DEFAULT_OPTION,
|
DEFAULT_OPTION,
|
||||||
{ id: 2, label: "Percentile", value: "percentile", disabled: false },
|
{ id: 2, label: "Percentile", value: "percentile", disabled: false },
|
||||||
{ id: 3, label: "GitHub", value: "github", 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 {
|
interface StatsRankSectionProps {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
|
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useDebouncedField } from "../../hooks/useDebouncedField";
|
||||||
|
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
|
|
||||||
interface TextSectionProps {
|
interface TextSectionProps {
|
||||||
@@ -25,29 +26,11 @@ export function TextSection({
|
|||||||
placeholder,
|
placeholder,
|
||||||
onPaste,
|
onPaste,
|
||||||
}: TextSectionProps): JSX.Element {
|
}: TextSectionProps): JSX.Element {
|
||||||
const [internalValue, setInternalValue] = useState(value);
|
const { inputValue, setInputValue } = useDebouncedField({
|
||||||
const debounceTimeout = useRef<number | null>(null);
|
value,
|
||||||
|
onValueChange,
|
||||||
useEffect(() => {
|
type: "text",
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={title}>
|
<Section title={title}>
|
||||||
@@ -55,12 +38,12 @@ export function TextSection({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border border-gray-300 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
|
"border border-base-content/20 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 },
|
disabled ? "cursor-not-allowed bg-base-200" : "bg-base-100",
|
||||||
)}
|
)}
|
||||||
value={internalValue}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInternalValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const options: Array<SelectOption> = [
|
|||||||
id: 3,
|
id: 3,
|
||||||
label: "Text Only",
|
label: "Text Only",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: "default&hide_progress=true&card_width=315",
|
value: "hide_progress",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const USE_LOGGER = true as boolean;
|
export const USE_LOGGER = true as boolean;
|
||||||
|
|
||||||
export const CLIENT_ID = "Ov23lilAc5biyyRY0K1u";
|
export const CLIENT_ID = "Ov23liZSweT9LJrck9i8";
|
||||||
|
|
||||||
export const HOST = window.location.host;
|
export const HOST = window.location.host;
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
@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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Segoe UI", Ubuntu, Sans-Serif;
|
font-family: "Segoe UI", Ubuntu, Sans-Serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
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": {
|
"repositories": {
|
||||||
"totalCount": 139,
|
"totalCount": 139,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{ "name": "classicLogo", "stargazers": { "totalCount": 1 } },
|
{ "name": "classicLogo", "stargazerCount": 1 },
|
||||||
{ "name": "creativechat", "stargazers": { "totalCount": 1 } },
|
{ "name": "creativechat", "stargazerCount": 1 },
|
||||||
{ "name": "verlet_tests", "stargazers": { "totalCount": 1 } },
|
{ "name": "verlet_tests", "stargazerCount": 1 },
|
||||||
{
|
{ "name": "blade-checkout-ui-demo", "stargazerCount": 0 },
|
||||||
"name": "blade-checkout-ui-demo",
|
{ "name": "tslint-plugin-analytics", "stargazerCount": 0 },
|
||||||
"stargazers": { "totalCount": 0 }
|
{ "name": "stargazer", "stargazerCount": 0 },
|
||||||
},
|
{ "name": "react-smooth-range-input", "stargazerCount": 0 },
|
||||||
{
|
{ "name": "csb-git-integration-bug", "stargazerCount": 0 },
|
||||||
"name": "tslint-plugin-analytics",
|
{ "name": "covid-nepal-web", "stargazerCount": 0 },
|
||||||
"stargazers": { "totalCount": 0 }
|
{ "name": "shrijan00003.github.io", "stargazerCount": 0 },
|
||||||
},
|
{ "name": "TheBigCB.com", "stargazerCount": 0 },
|
||||||
{ "name": "stargazer", "stargazers": { "totalCount": 0 } },
|
{ "name": "html2commonmark", "stargazerCount": 0 },
|
||||||
{
|
{ "name": "test-myaction", "stargazerCount": 0 },
|
||||||
"name": "react-smooth-range-input",
|
{ "name": "react-emoji-render", "stargazerCount": 0 },
|
||||||
"stargazers": { "totalCount": 0 }
|
{ "name": "sajilo-editor", "stargazerCount": 0 },
|
||||||
},
|
{ "name": "coder-aayush.github.io", "stargazerCount": 0 },
|
||||||
{
|
{ "name": "codesandbox-client", "stargazerCount": 0 },
|
||||||
"name": "csb-git-integration-bug",
|
{ "name": "product-sans", "stargazerCount": 0 },
|
||||||
"stargazers": { "totalCount": 0 }
|
{ "name": "NodejsBlogSystem", "stargazerCount": 0 },
|
||||||
},
|
{ "name": "native-javascript", "stargazerCount": 0 },
|
||||||
{ "name": "covid-nepal-web", "stargazers": { "totalCount": 0 } },
|
{ "name": "p5.js", "stargazerCount": 0 },
|
||||||
{
|
{ "name": "gatsbytutorials.com", "stargazerCount": 0 },
|
||||||
"name": "shrijan00003.github.io",
|
{ "name": "hello-world-docker-action", "stargazerCount": 0 },
|
||||||
"stargazers": { "totalCount": 0 }
|
{ "name": "glugmvit.github.io", "stargazerCount": 0 },
|
||||||
},
|
{ "name": "react-map-interaction", "stargazerCount": 0 },
|
||||||
{ "name": "TheBigCB.com", "stargazers": { "totalCount": 0 } },
|
{ "name": "react-external-image", "stargazerCount": 0 },
|
||||||
{ "name": "html2commonmark", "stargazers": { "totalCount": 0 } },
|
{ "name": "gatsby-boilerplate", "stargazerCount": 0 },
|
||||||
{ "name": "test-myaction", "stargazers": { "totalCount": 0 } },
|
{ "name": "YouBackup", "stargazerCount": 0 },
|
||||||
{ "name": "react-emoji-render", "stargazers": { "totalCount": 0 } },
|
{ "name": "npm-expansions", "stargazerCount": 0 },
|
||||||
{ "name": "sajilo-editor", "stargazers": { "totalCount": 0 } },
|
{ "name": "Toy-Neural-Network-JS", "stargazerCount": 0 },
|
||||||
{
|
{ "name": "LSystemCreator", "stargazerCount": 0 },
|
||||||
"name": "coder-aayush.github.io",
|
{ "name": "Happy-2019", "stargazerCount": 0 },
|
||||||
"stargazers": { "totalCount": 0 }
|
{ "name": "jquery-navScroll", "stargazerCount": 0 },
|
||||||
},
|
{ "name": "strml.net", "stargazerCount": 0 },
|
||||||
{ "name": "codesandbox-client", "stargazers": { "totalCount": 0 } },
|
{ "name": "Logo", "stargazerCount": 0 },
|
||||||
{ "name": "product-sans", "stargazers": { "totalCount": 0 } },
|
{ "name": "website", "stargazerCount": 0 },
|
||||||
{ "name": "NodejsBlogSystem", "stargazers": { "totalCount": 0 } },
|
{ "name": "anuraghazra.github.io-old", "stargazerCount": 0 },
|
||||||
{ "name": "native-javascript", "stargazers": { "totalCount": 0 } },
|
{ "name": "Quintus", "stargazerCount": 0 },
|
||||||
{ "name": "p5.js", "stargazers": { "totalCount": 0 } },
|
{ "name": "Electron_experiments", "stargazerCount": 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 } }
|
|
||||||
],
|
],
|
||||||
"pageInfo": {
|
"pageInfo": {
|
||||||
"hasNextPage": false,
|
"hasNextPage": false,
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
"isPrivate": false,
|
"isPrivate": false,
|
||||||
"isArchived": false,
|
"isArchived": false,
|
||||||
"isTemplate": false,
|
"isTemplate": false,
|
||||||
"stargazers": {
|
"stargazerCount": 76994,
|
||||||
"totalCount": 76994
|
|
||||||
},
|
|
||||||
"description": ":zap: Dynamically generated stats for your github readmes",
|
"description": ":zap: Dynamically generated stats for your github readmes",
|
||||||
"primaryLanguage": {
|
"primaryLanguage": {
|
||||||
"color": "#f1e05a",
|
"color": "#f1e05a",
|
||||||
|
|||||||
@@ -36,606 +36,109 @@
|
|||||||
"repositories": {
|
"repositories": {
|
||||||
"totalCount": 139,
|
"totalCount": 139,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{ "name": "github-readme-stats", "stargazerCount": 77025 },
|
||||||
"name": "github-readme-stats",
|
{ "name": "Verly.js", "stargazerCount": 696 },
|
||||||
"stargazers": {
|
{ "name": "anuraghazra.github.io", "stargazerCount": 608 },
|
||||||
"totalCount": 77025
|
{ "name": "anuraghazra", "stargazerCount": 376 },
|
||||||
}
|
{ "name": "type-trident", "stargazerCount": 359 },
|
||||||
},
|
{ "name": "BugVilla", "stargazerCount": 232 },
|
||||||
{
|
{ "name": "convoychat", "stargazerCount": 214 },
|
||||||
"name": "Verly.js",
|
{ "name": "CanvasFun", "stargazerCount": 211 },
|
||||||
"stargazers": {
|
{ "name": "EvolutionAquerium", "stargazerCount": 192 },
|
||||||
"totalCount": 696
|
{ "name": "typelevel-parser", "stargazerCount": 147 },
|
||||||
}
|
{ "name": "VerlyRangeSlider", "stargazerCount": 135 },
|
||||||
},
|
{ "name": "vscode-strip-ts-copy", "stargazerCount": 72 },
|
||||||
{
|
{ "name": "react-folder-tree", "stargazerCount": 54 },
|
||||||
"name": "anuraghazra.github.io",
|
{ "name": "Atomic.js", "stargazerCount": 53 },
|
||||||
"stargazers": {
|
{ "name": "ToyLang", "stargazerCount": 51 },
|
||||||
"totalCount": 608
|
{ "name": "design-patterns-everyday", "stargazerCount": 46 },
|
||||||
}
|
{ "name": "gatsby", "stargazerCount": 36 },
|
||||||
},
|
{ "name": "Slime", "stargazerCount": 35 },
|
||||||
{
|
{ "name": "Candy.js", "stargazerCount": 34 },
|
||||||
"name": "anuraghazra",
|
{ "name": "ShaderExpo", "stargazerCount": 33 },
|
||||||
"stargazers": {
|
{ "name": "Nothing", "stargazerCount": 32 },
|
||||||
"totalCount": 376
|
{ "name": "peerlist-profile-action", "stargazerCount": 30 },
|
||||||
}
|
{ "name": "facebook-reaction-animation", "stargazerCount": 29 },
|
||||||
},
|
{ "name": "react-stripe-dropdown", "stargazerCount": 27 },
|
||||||
{
|
{ "name": "QuickerPoll", "stargazerCount": 27 },
|
||||||
"name": "type-trident",
|
{ "name": "playground-format-on-save", "stargazerCount": 24 },
|
||||||
"stargazers": {
|
{ "name": "ParticleBrush", "stargazerCount": 23 },
|
||||||
"totalCount": 359
|
{ "name": "graphql-oauth", "stargazerCount": 21 },
|
||||||
}
|
{ "name": "parasites", "stargazerCount": 21 },
|
||||||
},
|
{ "name": "twitter-banner-jokes", "stargazerCount": 18 },
|
||||||
{
|
{ "name": "verly-cpp", "stargazerCount": 16 },
|
||||||
"name": "BugVilla",
|
{ "name": "gatsby-github-issues-blog", "stargazerCount": 16 },
|
||||||
"stargazers": {
|
{ "name": "NikeHyperAce", "stargazerCount": 12 },
|
||||||
"totalCount": 232
|
{ "name": "rust-particles", "stargazerCount": 11 },
|
||||||
}
|
{ "name": "VerletDrawing", "stargazerCount": 10 },
|
||||||
},
|
{ "name": "graphql-jwt-auth-test", "stargazerCount": 9 },
|
||||||
{
|
|
||||||
"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": "anuraghazra.github.io-old-react",
|
"name": "anuraghazra.github.io-old-react",
|
||||||
"stargazers": {
|
"stargazerCount": 9
|
||||||
"totalCount": 9
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{ "name": "react18-ssr-test", "stargazerCount": 8 },
|
||||||
"name": "react18-ssr-test",
|
{ "name": "GyroDodge", "stargazerCount": 8 },
|
||||||
"stargazers": {
|
{ "name": "go-phyllotaxis", "stargazerCount": 6 },
|
||||||
"totalCount": 8
|
{ "name": "graphql-oauth-client", "stargazerCount": 6 },
|
||||||
}
|
{ "name": "code-runner-action", "stargazerCount": 6 },
|
||||||
},
|
{ "name": "GithubActionsPlayground", "stargazerCount": 6 },
|
||||||
{
|
{ "name": "Loader.js", "stargazerCount": 6 },
|
||||||
"name": "GyroDodge",
|
{ "name": "ts-essentials", "stargazerCount": 5 },
|
||||||
"stargazers": {
|
{ "name": "chakra-ui", "stargazerCount": 5 },
|
||||||
"totalCount": 8
|
{ "name": "text-to-handwriting", "stargazerCount": 5 },
|
||||||
}
|
{ "name": "build-your-own-x", "stargazerCount": 5 },
|
||||||
},
|
{ "name": "nodejs-dynamic-restapi", "stargazerCount": 5 },
|
||||||
{
|
{ "name": "DevYouTubeList", "stargazerCount": 5 },
|
||||||
"name": "go-phyllotaxis",
|
{ "name": "FlockingBlackHole", "stargazerCount": 5 },
|
||||||
"stargazers": {
|
{ "name": "webgpu-boids", "stargazerCount": 4 },
|
||||||
"totalCount": 6
|
{ "name": "test-githubblock", "stargazerCount": 4 },
|
||||||
}
|
{ "name": "renderless-components", "stargazerCount": 4 },
|
||||||
},
|
{ "name": "reakit", "stargazerCount": 4 },
|
||||||
{
|
{ "name": "micro-open-graph", "stargazerCount": 4 },
|
||||||
"name": "graphql-oauth-client",
|
{ "name": "rest-and-graphql", "stargazerCount": 4 },
|
||||||
"stargazers": {
|
{ "name": "abell-issues-blog", "stargazerCount": 4 },
|
||||||
"totalCount": 6
|
{ "name": "VanillaMVC", "stargazerCount": 4 },
|
||||||
}
|
{ "name": "covid19-sim", "stargazerCount": 4 },
|
||||||
},
|
{ "name": "YoutubeDownloader", "stargazerCount": 4 },
|
||||||
{
|
{ "name": "verlet.js", "stargazerCount": 4 },
|
||||||
"name": "code-runner-action",
|
{ "name": "test-github-ts", "stargazerCount": 3 },
|
||||||
"stargazers": {
|
{ "name": "tw-dynamic-interpolation", "stargazerCount": 3 },
|
||||||
"totalCount": 6
|
{ "name": "ts-string-interpolator", "stargazerCount": 3 },
|
||||||
}
|
{ "name": "AnuReact", "stargazerCount": 3 },
|
||||||
},
|
{ "name": "CanvasBalls", "stargazerCount": 3 },
|
||||||
{
|
{ "name": "circleci-test", "stargazerCount": 3 },
|
||||||
"name": "GithubActionsPlayground",
|
{ "name": "gatsby-starter-netlify-cms", "stargazerCount": 3 },
|
||||||
"stargazers": {
|
{ "name": "WebGL.js", "stargazerCount": 3 },
|
||||||
"totalCount": 6
|
{ "name": "test-fork-workflow", "stargazerCount": 2 },
|
||||||
}
|
{ "name": "test-codesandbox-projects", "stargazerCount": 2 },
|
||||||
},
|
{ "name": "codesandbox-template-astro", "stargazerCount": 2 },
|
||||||
{
|
{ "name": "apollo", "stargazerCount": 2 },
|
||||||
"name": "Loader.js",
|
{ "name": "api-docs", "stargazerCount": 2 },
|
||||||
"stargazers": {
|
{ "name": "Buddies-movie", "stargazerCount": 2 },
|
||||||
"totalCount": 6
|
{ "name": "2k76", "stargazerCount": 2 },
|
||||||
}
|
{ "name": "gatsby-remark-embedder", "stargazerCount": 2 },
|
||||||
},
|
{ "name": "weird-hello-worlds", "stargazerCount": 2 },
|
||||||
{
|
{ "name": "rn-tabview-test-repro", "stargazerCount": 1 },
|
||||||
"name": "ts-essentials",
|
{ "name": "tailwindcss-jit", "stargazerCount": 1 },
|
||||||
"stargazers": {
|
{ "name": "DefinitelyTyped", "stargazerCount": 1 },
|
||||||
"totalCount": 5
|
{ "name": "server-components-demo", "stargazerCount": 1 },
|
||||||
}
|
{ "name": "volant-vo", "stargazerCount": 1 },
|
||||||
},
|
{ "name": "react-spectrum", "stargazerCount": 1 },
|
||||||
{
|
{ "name": "webhooks-test", "stargazerCount": 1 },
|
||||||
"name": "chakra-ui",
|
{ "name": "react-showdown", "stargazerCount": 1 },
|
||||||
"stargazers": {
|
{ "name": "transcode", "stargazerCount": 1 },
|
||||||
"totalCount": 5
|
{ "name": "awake-yet-action", "stargazerCount": 1 },
|
||||||
}
|
{ "name": "bradgarropy.com", "stargazerCount": 1 },
|
||||||
},
|
{ "name": "gatsby-plugin-social-banners", "stargazerCount": 1 },
|
||||||
{
|
{ "name": "Tour360", "stargazerCount": 1 },
|
||||||
"name": "text-to-handwriting",
|
{ "name": "blizard.js", "stargazerCount": 1 },
|
||||||
"stargazers": {
|
{ "name": "keep-a-changelog", "stargazerCount": 1 },
|
||||||
"totalCount": 5
|
{ "name": "vulnerabilities", "stargazerCount": 1 },
|
||||||
}
|
{ "name": "harleydavidson", "stargazerCount": 1 },
|
||||||
},
|
{ "name": "awesome-webgl", "stargazerCount": 1 },
|
||||||
{
|
{ "name": "NewtonsInterpolation", "stargazerCount": 1 },
|
||||||
"name": "build-your-own-x",
|
{ "name": "now-github-starter", "stargazerCount": 1 },
|
||||||
"stargazers": {
|
{ "name": "simplerockets", "stargazerCount": 1 }
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"pageInfo": {
|
"pageInfo": {
|
||||||
"hasNextPage": true,
|
"hasNextPage": true,
|
||||||
|
|||||||
@@ -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",
|
title: "Login",
|
||||||
shortTitle: "Login",
|
shortTitle: "Login",
|
||||||
|
description: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Select a Card",
|
title: "Select a Card",
|
||||||
shortTitle: "Select Card",
|
shortTitle: "Select Card",
|
||||||
|
description: "You will be able to customize your card in future steps.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Modify Card Parameters",
|
title: "Modify Card Parameters",
|
||||||
shortTitle: "Modify Parameters",
|
shortTitle: "Modify Parameters",
|
||||||
|
description: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Choose a Theme",
|
title: "Choose a Theme",
|
||||||
shortTitle: "Select Theme",
|
shortTitle: "Select Theme",
|
||||||
|
description: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Display your Card",
|
title: "Display your Card",
|
||||||
shortTitle: "Display 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 = {
|
export type StageIndex = {
|
||||||
[K in keyof typeof STAGE_LABELS]: K extends `${infer N extends number}`
|
[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 { useDispatch } from "react-redux";
|
||||||
import { ToastContainer, toast } from "react-toastify";
|
import { ToastContainer, toast } from "react-toastify";
|
||||||
|
|
||||||
import { getUserMetadata } from "../../api/user";
|
import { getUserMetadata } from "../../api/user";
|
||||||
import { clearAxiosCache } from "../../axios-override";
|
import { clearAxiosCache } from "../../axios-override";
|
||||||
import type { StageIndex } from "../../models/Stage";
|
import type { StageIndex } from "../../models/Stage";
|
||||||
|
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||||
import {
|
import {
|
||||||
useIsAuthenticated,
|
useIsAuthenticated,
|
||||||
useUserKey,
|
useUserKey,
|
||||||
@@ -56,6 +57,7 @@ export function AppTrends() {
|
|||||||
const userKey = useUserKey();
|
const userKey = useUserKey();
|
||||||
const userToken = useUserToken();
|
const userToken = useUserToken();
|
||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
|
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
|
||||||
|
|
||||||
@@ -79,24 +81,20 @@ export function AppTrends() {
|
|||||||
clearAxiosCache();
|
clearAxiosCache();
|
||||||
}, [userToken]);
|
}, [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
|
||||||
|
*/
|
||||||
{
|
{
|
||||||
/*
|
const [prevIsAuthenticated, setPrevIsAuthenticated] =
|
||||||
* This effect must only be executed when `isAuthenticated` changes,
|
useState(isAuthenticated);
|
||||||
* otherwise logged-in user are unable to go back to step 1.
|
if (isAuthenticated !== prevIsAuthenticated) {
|
||||||
*/
|
setPrevIsAuthenticated(isAuthenticated);
|
||||||
const prevIsAuthenticated = useRef(isAuthenticated);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevIsAuthenticated.current === isAuthenticated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIsAuthenticated.current = isAuthenticated;
|
|
||||||
|
|
||||||
if (isAuthenticated && stage === 0) {
|
if (isAuthenticated && stage === 0) {
|
||||||
setStage(1);
|
setStage(1);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, stage]);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,9 +114,9 @@ export function AppTrends() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<Header currStageIndex={stage} onStageIndexChange={setStage} />
|
<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} />
|
<HomeScreen stage={stage} setStage={setStage} />
|
||||||
<ToastContainer />
|
<ToastContainer theme={isDark ? "dark" : "light"} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { JSX } from "react";
|
|||||||
import { FaGithub as GithubIcon } from "react-icons/fa";
|
import { FaGithub as GithubIcon } from "react-icons/fa";
|
||||||
|
|
||||||
import appIcon from "../../assets/appLogo64.png";
|
import appIcon from "../../assets/appLogo64.png";
|
||||||
|
import { ThemePicker } from "../../components/Generic/ThemePicker";
|
||||||
import { ProgressBar } from "../../components/Home/Progress";
|
import { ProgressBar } from "../../components/Home/Progress";
|
||||||
import { STAGE_LABELS } from "../../models/Stage";
|
import { STAGE_LABELS } from "../../models/Stage";
|
||||||
import type { StageIndex } from "../../models/Stage";
|
import type { StageIndex } from "../../models/Stage";
|
||||||
@@ -19,31 +20,34 @@ export function Header({
|
|||||||
}: HeaderProps): JSX.Element {
|
}: HeaderProps): JSX.Element {
|
||||||
return (
|
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">
|
<div className="px-5 py-2 flex flex-wrap">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<a
|
<a
|
||||||
href="/"
|
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" />
|
<img src={appIcon} alt="logo" className="w-6 h-6" />
|
||||||
<span className="ml-2 text-xl">GitHub Stats Extended</span>
|
<span className="ml-2 text-xl">GitHub Stats Extended</span>
|
||||||
</a>
|
</a>
|
||||||
{/* Star on GitHub */}
|
{/* Star on GitHub + theme toggle */}
|
||||||
<div className="flex ml-auto items-center text-base justify-center">
|
<div className="flex ml-auto items-center gap-2 text-base justify-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/stats-organization/github-stats-extended"
|
href="https://github.com/stats-organization/github-stats-extended"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-[0.25rem] shadow bg-neutral-content hover:opacity-90 text-neutral px-3 py-1 flex items-center"
|
||||||
>
|
>
|
||||||
<button
|
Star on
|
||||||
type="button"
|
<GithubIcon className="ml-1.5 w-5 h-5" />
|
||||||
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>
|
|
||||||
</a>
|
</a>
|
||||||
|
<ThemePicker />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import axios from "axios";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { BounceLoader } from "react-spinners";
|
import { BounceLoader } from "react-spinners";
|
||||||
@@ -7,18 +6,12 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
|
|
||||||
import { authenticate } from "../../api/user";
|
import { authenticate } from "../../api/user";
|
||||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
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 { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||||
import {
|
import { DEMO_USER } from "../../constants";
|
||||||
DEMO_GIST,
|
|
||||||
DEMO_REPO,
|
|
||||||
DEMO_USER,
|
|
||||||
DEMO_WAKATIME_USER,
|
|
||||||
HOST,
|
|
||||||
} from "../../constants";
|
|
||||||
import { CardType } from "../../models/CardType";
|
import { CardType } from "../../models/CardType";
|
||||||
import { STAGE_LABELS } from "../../models/Stage";
|
import { STAGE_LABELS } from "../../models/Stage";
|
||||||
import type { StageIndex } from "../../models/Stage";
|
import type { StageIndex } from "../../models/Stage";
|
||||||
|
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||||
import {
|
import {
|
||||||
useIsAuthenticated,
|
useIsAuthenticated,
|
||||||
usePrivateAccess,
|
usePrivateAccess,
|
||||||
@@ -26,12 +19,15 @@ import {
|
|||||||
} from "../../redux/selectors/userSelectors";
|
} from "../../redux/selectors/userSelectors";
|
||||||
import { login } from "../../redux/slices/user";
|
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 { CustomizeStage } from "./stages/Customize";
|
||||||
import { DisplayStage } from "./stages/Display";
|
import { DisplayStage } from "./stages/Display";
|
||||||
import { LoginStage } from "./stages/Login/Login";
|
import { LoginStage } from "./stages/Login/Login";
|
||||||
import { SelectCardStage } from "./stages/SelectCard";
|
import { SelectCardStage } from "./stages/SelectCard";
|
||||||
import { ThemeStage } from "./stages/Theme";
|
import { ThemeStage } from "./stages/Theme";
|
||||||
|
import { useCardDescriptor } from "./useCardDescriptor";
|
||||||
|
|
||||||
interface HomeScreenProps {
|
interface HomeScreenProps {
|
||||||
stage: StageIndex;
|
stage: StageIndex;
|
||||||
@@ -47,51 +43,44 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
|
|
||||||
const dispatch = useDispatch();
|
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);
|
const [selectedCard, setSelectedCard] = useState<CardType>(CardType.STATS);
|
||||||
|
|
||||||
useEffect(() => {
|
// for stages two and three
|
||||||
setSelectedUserId(userId);
|
const [cardOptions, setCardOptions] = useState(() =>
|
||||||
}, [userId]);
|
getDefaultCardOptions(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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showTitle, setShowTitle] = useState(true);
|
const setCardOption = useCallback<
|
||||||
const [showOwner, setShowOwner] = useState(false);
|
<K extends keyof CardOptions>(key: K, value: CardOptions[K]) => void
|
||||||
const [descriptionLines, setDescriptionLines] = useState<
|
>((key, value) => {
|
||||||
number | undefined
|
setCardOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
>();
|
}, []);
|
||||||
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 [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 [theme, setTheme] = useState(isDark ? "dark" : "default");
|
||||||
|
|
||||||
const handleCardTypeChange = (cardType: CardType) => {
|
const handleCardTypeChange = (cardType: CardType) => {
|
||||||
if (cardType === CardType.TOP_LANGS) {
|
if (cardType === CardType.TOP_LANGS) {
|
||||||
setLangsCount(4);
|
setCardOptions((prev) => ({
|
||||||
setSelectedWakatimeLayout(WAKATIME_DEFAULT_LAYOUT);
|
...prev,
|
||||||
|
langsCount: 4,
|
||||||
|
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
|
||||||
|
}));
|
||||||
} else if (cardType === CardType.WAKATIME) {
|
} else if (cardType === CardType.WAKATIME) {
|
||||||
setLangsCount(6);
|
setCardOptions((prev) => ({
|
||||||
setSelectedLanguagesLayout(LANGUAGES_DEFAULT_LAYOUT);
|
...prev,
|
||||||
|
langsCount: 6,
|
||||||
|
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (theme === "default" || theme === "default_repocard") {
|
if (theme === "default" || theme === "default_repocard") {
|
||||||
@@ -107,77 +96,41 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
setStage(2);
|
setStage(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fullSuffix = getFullSuffix({
|
// Memoized so the builder keeps a stable reference across renders when its
|
||||||
userId,
|
// inputs are unchanged. Card components consume it by value (they derive a URL
|
||||||
selectedCard,
|
// string), but memoizing keeps it safe to pass as a prop if any of them are
|
||||||
selectedUserId,
|
// ever wrapped in React.memo.
|
||||||
repo,
|
const cardBuilder = useMemo(
|
||||||
gist,
|
() => buildCardUrl(userId, selectedCard, cardOptions),
|
||||||
wakatimeUser,
|
[userId, selectedCard, cardOptions],
|
||||||
selectedStatsRank,
|
);
|
||||||
selectedLanguagesLayout,
|
|
||||||
selectedWakatimeLayout,
|
// Preview builder for the customize stage, dark-themed to match the surroundings.
|
||||||
showTitle,
|
const customizeCardBuilder = useMemo(
|
||||||
showOwner,
|
() => (isDark ? cardBuilder.theme("github_dark") : cardBuilder),
|
||||||
descriptionLines,
|
[cardBuilder, isDark],
|
||||||
customTitle,
|
);
|
||||||
langsCount,
|
|
||||||
hideValues,
|
|
||||||
showAllStats,
|
|
||||||
showIcons,
|
|
||||||
includeAllCommits,
|
|
||||||
enableAnimations,
|
|
||||||
usePercent,
|
|
||||||
});
|
|
||||||
|
|
||||||
// for stage four
|
// for stage four
|
||||||
let themeSuffix = fullSuffix;
|
const isRepoCard =
|
||||||
|
selectedCard === CardType.PIN || selectedCard === CardType.GIST;
|
||||||
|
const defaultTheme = isRepoCard ? "default_repocard" : "default";
|
||||||
|
const isDefaultTheme = theme === defaultTheme;
|
||||||
|
|
||||||
if (
|
const themeBuilder = useMemo(
|
||||||
!(
|
() => (isDefaultTheme ? cardBuilder : cardBuilder.theme(theme)),
|
||||||
(theme === "default" &&
|
[cardBuilder, isDefaultTheme, theme],
|
||||||
[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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// for stage five
|
// for stage five
|
||||||
const [gistUrl, setGistUrl] = useState("");
|
const cardDescriptor = useCardDescriptor({
|
||||||
|
selectedCard,
|
||||||
const guestHint = useMemo(() => {
|
themeBuilder,
|
||||||
switch (selectedCard) {
|
repo: cardOptions.repo,
|
||||||
case CardType.STATS:
|
userId,
|
||||||
case CardType.TOP_LANGS:
|
wakatimeUser: cardOptions.wakatimeUser,
|
||||||
return `username "${DEMO_USER}"`;
|
gist: cardOptions.gist,
|
||||||
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 contentSectionRef = useRef<HTMLDivElement | null>(null);
|
const contentSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -194,31 +147,31 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
}, [stage]);
|
}, [stage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function redirectCode() {
|
// After requesting GitHub access, GitHub redirects back to the app with a
|
||||||
// After requesting GitHub access, GitHub redirects back to your app with a code parameter
|
// code parameter appended to the redirect URI (which carries mode=private|public).
|
||||||
const url = window.location.href;
|
const url = new URL(window.location.href);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
if (code === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If GitHub API returns the code parameter
|
const tempPrivateAccess = url.searchParams.get("mode") === "private";
|
||||||
if (url.includes("code=")) {
|
window.history.pushState({}, "", `${url.origin}/frontend`);
|
||||||
const tempPrivateAccess = url.includes("private");
|
|
||||||
const newUrl = url.split("code=", 2) as [string, string];
|
async function exchangeCode(code: string) {
|
||||||
const redirect = `${url.split(HOST)[0] as string}${HOST}/frontend`;
|
setIsLoading(true);
|
||||||
window.history.pushState({}, "", redirect);
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const userKey = uuidv4();
|
const userKey = uuidv4();
|
||||||
const newUserId = await authenticate(
|
const newUserId = await authenticate(code, tempPrivateAccess, userKey);
|
||||||
newUrl[1],
|
|
||||||
tempPrivateAccess,
|
|
||||||
userKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch(login({ userId: newUserId, userKey }));
|
dispatch(login({ userId: newUserId, userKey }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void redirectCode();
|
void exchangeCode(code);
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -230,11 +183,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="flex flex-col">
|
||||||
<div className="m-4 rounded-sm">
|
<div className="m-4 rounded-sm">
|
||||||
<div className="lg:p-4">
|
<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}
|
{STAGE_LABELS[stage].title}
|
||||||
</h1>
|
</h1>
|
||||||
<div>
|
<div>
|
||||||
@@ -245,7 +201,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
<a
|
<a
|
||||||
href={`https://github.com/${userId}`}
|
href={`https://github.com/${userId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-blue-500 hover:underline font-semibold"
|
className="text-primary hover:underline font-semibold"
|
||||||
>
|
>
|
||||||
{userId}
|
{userId}
|
||||||
</a>
|
</a>
|
||||||
@@ -266,13 +222,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
[
|
STAGE_LABELS[stage].description
|
||||||
"",
|
|
||||||
"You will be able to customize your card in future steps.",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
|
|
||||||
][stage]
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,49 +242,15 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
{stage === 2 && (
|
{stage === 2 && (
|
||||||
<CustomizeStage
|
<CustomizeStage
|
||||||
selectedCard={selectedCard}
|
selectedCard={selectedCard}
|
||||||
selectedStatsRank={selectedStatsRank}
|
options={cardOptions}
|
||||||
setSelectedStatsRank={setSelectedStatsRank}
|
onOptionChange={setCardOption}
|
||||||
selectedLanguagesLayout={selectedLanguagesLayout}
|
card={customizeCardBuilder}
|
||||||
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}
|
|
||||||
setStage={setStage}
|
setStage={setStage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{stage === 3 && (
|
{stage === 3 && (
|
||||||
<ThemeStage
|
<ThemeStage
|
||||||
fullSuffix={fullSuffix}
|
card={cardBuilder}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onThemeChange={(theme) => {
|
onThemeChange={(theme) => {
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
@@ -344,49 +260,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{stage === 4 && (
|
{stage === 4 && (
|
||||||
<DisplayStage
|
<DisplayStage
|
||||||
filename={(() => {
|
filename={cardBuilder.filename()}
|
||||||
switch (selectedCard) {
|
link={cardDescriptor.link}
|
||||||
case CardType.STATS:
|
theme={theme}
|
||||||
case CardType.TOP_LANGS:
|
card={themeBuilder}
|
||||||
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}
|
|
||||||
guestHint={
|
guestHint={
|
||||||
isAuthenticated
|
isAuthenticated
|
||||||
? null
|
? 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 { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||||
import { CardType } from "../../models/CardType";
|
import { CardType } from "../../models/CardType";
|
||||||
|
|
||||||
import { getFullSuffix } from "./getFullSuffix";
|
import { buildCardUrl } from "./buildCardUrl";
|
||||||
|
import type { CardOptions } from "./cardOptions";
|
||||||
|
|
||||||
const baseOptions = {
|
const USER_ID = "john-github";
|
||||||
selectedCard: CardType.STATS,
|
|
||||||
|
// 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",
|
selectedUserId: "john",
|
||||||
userId: "john-github",
|
|
||||||
repo: "repo1",
|
repo: "repo1",
|
||||||
gist: "gist1",
|
gist: "gist1",
|
||||||
wakatimeUser: "wakaUser",
|
wakatimeUser: "wakaUser",
|
||||||
@@ -30,15 +33,15 @@ const baseOptions = {
|
|||||||
usePercent: false,
|
usePercent: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("getFullSuffix", () => {
|
describe("buildCardUrl", () => {
|
||||||
it("builds stats suffix with defaults", () => {
|
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", () => {
|
it("adds stats options", () => {
|
||||||
const result = getFullSuffix({
|
const result = buildCardUrl(USER_ID, CardType.STATS, {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
showIcons: true,
|
showIcons: true,
|
||||||
includeAllCommits: true,
|
includeAllCommits: true,
|
||||||
@@ -46,73 +49,69 @@ describe("getFullSuffix", () => {
|
|||||||
showTitle: false,
|
showTitle: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(
|
expect(result.toString()).toBe(
|
||||||
"?username=john" +
|
"?username=john" +
|
||||||
"&hide_title=true" +
|
"&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" +
|
"&show_icons=true" +
|
||||||
"&include_all_commits=true",
|
"&include_all_commits=true",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds top-langs suffix", () => {
|
it("builds top-langs suffix", () => {
|
||||||
const result = getFullSuffix({
|
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
selectedCard: CardType.TOP_LANGS,
|
|
||||||
langsCount: 5,
|
langsCount: 5,
|
||||||
showTitle: false,
|
showTitle: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(
|
expect(result.toString()).toBe(
|
||||||
"/top-langs?username=john&hide_title=true&langs_count=5",
|
"/top-langs?username=john&hide_title=true&langs_count=5",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds pin suffix using userId not selectedUserId", () => {
|
it("builds pin suffix using userId not selectedUserId", () => {
|
||||||
const result = getFullSuffix({
|
const result = buildCardUrl(USER_ID, CardType.PIN, {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
selectedCard: CardType.PIN,
|
|
||||||
showOwner: true,
|
showOwner: true,
|
||||||
descriptionLines: 3,
|
descriptionLines: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(
|
expect(result.toString()).toBe(
|
||||||
"/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3",
|
"/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds gist suffix", () => {
|
it("builds gist suffix", () => {
|
||||||
const result = getFullSuffix({
|
const result = buildCardUrl(USER_ID, CardType.GIST, {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
selectedCard: CardType.GIST,
|
|
||||||
showOwner: true,
|
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", () => {
|
it("builds wakatime suffix with percent and custom title", () => {
|
||||||
const result = getFullSuffix({
|
const result = buildCardUrl(USER_ID, CardType.WAKATIME, {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
selectedCard: CardType.WAKATIME,
|
|
||||||
wakatimeUser: "waka",
|
wakatimeUser: "waka",
|
||||||
usePercent: true,
|
usePercent: true,
|
||||||
customTitle: "My Stats",
|
customTitle: "My Stats",
|
||||||
showTitle: false,
|
showTitle: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(
|
expect(result.toString()).toBe(
|
||||||
"/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent",
|
"/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds non-default layouts", () => {
|
it("adds non-default layouts", () => {
|
||||||
const result = getFullSuffix({
|
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
selectedCard: CardType.TOP_LANGS,
|
|
||||||
selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" },
|
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 { CardImage } from "../../../components/Card/CardImage";
|
||||||
import type { SelectOption } from "../../../components/Generic/Select";
|
|
||||||
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
|
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
|
||||||
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
|
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
|
||||||
import { NumericSection } from "../../../components/Home/NumericSection";
|
import { NumericSection } from "../../../components/Home/NumericSection";
|
||||||
@@ -15,101 +14,72 @@ import {
|
|||||||
DEMO_WAKATIME_USER,
|
DEMO_WAKATIME_USER,
|
||||||
} from "../../../constants";
|
} from "../../../constants";
|
||||||
import { CardType } from "../../../models/CardType";
|
import { CardType } from "../../../models/CardType";
|
||||||
|
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||||
import type { StageIndex } from "../../../models/Stage";
|
import type { StageIndex } from "../../../models/Stage";
|
||||||
import { useIsAuthenticated } from "../../../redux/selectors/userSelectors";
|
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 {
|
interface CustomizeStageProps {
|
||||||
selectedCard: CardType;
|
selectedCard: CardType;
|
||||||
selectedStatsRank: SelectOption;
|
options: CardOptions;
|
||||||
setSelectedStatsRank: Updater<SelectOption>;
|
onOptionChange: <K extends keyof CardOptions>(
|
||||||
selectedLanguagesLayout: SelectOption;
|
key: K,
|
||||||
setSelectedLanguagesLayout: Updater<SelectOption>;
|
value: CardOptions[K],
|
||||||
selectedWakatimeLayout: SelectOption;
|
) => void;
|
||||||
setSelectedWakatimeLayout: Updater<SelectOption>;
|
card: CardUrlBuilder;
|
||||||
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;
|
|
||||||
setStage: (stageIndex: StageIndex) => void;
|
setStage: (stageIndex: StageIndex) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomizeStage({
|
export function CustomizeStage({
|
||||||
selectedCard,
|
selectedCard,
|
||||||
selectedStatsRank,
|
options,
|
||||||
setSelectedStatsRank,
|
onOptionChange,
|
||||||
selectedLanguagesLayout,
|
card,
|
||||||
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,
|
|
||||||
setStage,
|
setStage,
|
||||||
}: CustomizeStageProps): JSX.Element {
|
}: CustomizeStageProps): JSX.Element {
|
||||||
const cardType = selectedCard;
|
const cardType = selectedCard;
|
||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedUserId,
|
||||||
|
repo,
|
||||||
|
gist,
|
||||||
|
wakatimeUser,
|
||||||
|
selectedStatsRank,
|
||||||
|
selectedLanguagesLayout,
|
||||||
|
selectedWakatimeLayout,
|
||||||
|
showTitle,
|
||||||
|
showOwner,
|
||||||
|
descriptionLines,
|
||||||
|
customTitle,
|
||||||
|
langsCount,
|
||||||
|
hideValues,
|
||||||
|
showAllStats,
|
||||||
|
showIcons,
|
||||||
|
includeAllCommits,
|
||||||
|
enableAnimations,
|
||||||
|
usePercent,
|
||||||
|
} = options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-wrap">
|
<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) && (
|
{(cardType === CardType.STATS || cardType === CardType.TOP_LANGS) && (
|
||||||
<TextSection
|
<TextSection
|
||||||
title="Username"
|
title="Username"
|
||||||
@@ -126,7 +96,7 @@ export function CustomizeStage({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStage(0);
|
setStage(0);
|
||||||
}}
|
}}
|
||||||
className="underline text-blue-900"
|
className="underline text-primary"
|
||||||
>
|
>
|
||||||
log in
|
log in
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@@ -137,19 +107,16 @@ export function CustomizeStage({
|
|||||||
}
|
}
|
||||||
placeholder={`e.g. "${DEMO_USER}"`}
|
placeholder={`e.g. "${DEMO_USER}"`}
|
||||||
value={selectedUserId}
|
value={selectedUserId}
|
||||||
onValueChange={setSelectedUserId}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("selectedUserId", value);
|
||||||
|
}}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let newValue = e.clipboardData.getData("text");
|
|
||||||
// if the user pasted a full GitHub URL, extract username
|
// if the user pasted a full GitHub URL, extract username
|
||||||
if (newValue.endsWith("/")) {
|
onOptionChange(
|
||||||
newValue = newValue.slice(0, -1);
|
"selectedUserId",
|
||||||
}
|
pastedPathTail(e.clipboardData.getData("text"), 1),
|
||||||
const parts = newValue.split("/");
|
);
|
||||||
if (parts.length > 1) {
|
|
||||||
newValue = parts.slice(-1).join("/");
|
|
||||||
}
|
|
||||||
setSelectedUserId(newValue);
|
|
||||||
}}
|
}}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated}
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +137,7 @@ export function CustomizeStage({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStage(0);
|
setStage(0);
|
||||||
}}
|
}}
|
||||||
className="underline text-blue-900"
|
className="underline text-primary"
|
||||||
>
|
>
|
||||||
log in
|
log in
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@@ -181,19 +148,16 @@ export function CustomizeStage({
|
|||||||
}
|
}
|
||||||
placeholder={`e.g. "${DEMO_REPO}"`}
|
placeholder={`e.g. "${DEMO_REPO}"`}
|
||||||
value={repo}
|
value={repo}
|
||||||
onValueChange={setRepo}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("repo", value);
|
||||||
|
}}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let newValue = e.clipboardData.getData("text");
|
|
||||||
// if the user pasted a full GitHub URL, extract owner/repo
|
// if the user pasted a full GitHub URL, extract owner/repo
|
||||||
if (newValue.endsWith("/")) {
|
onOptionChange(
|
||||||
newValue = newValue.slice(0, -1);
|
"repo",
|
||||||
}
|
pastedPathTail(e.clipboardData.getData("text"), 2),
|
||||||
const parts = newValue.split("/");
|
);
|
||||||
if (parts.length > 2) {
|
|
||||||
newValue = parts.slice(-2).join("/");
|
|
||||||
}
|
|
||||||
setRepo(newValue);
|
|
||||||
}}
|
}}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated}
|
||||||
/>
|
/>
|
||||||
@@ -214,7 +178,7 @@ export function CustomizeStage({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStage(0);
|
setStage(0);
|
||||||
}}
|
}}
|
||||||
className="underline text-blue-900"
|
className="underline text-primary"
|
||||||
>
|
>
|
||||||
log in
|
log in
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@@ -225,19 +189,16 @@ export function CustomizeStage({
|
|||||||
}
|
}
|
||||||
placeholder={`e.g. "${DEMO_GIST}"`}
|
placeholder={`e.g. "${DEMO_GIST}"`}
|
||||||
value={gist}
|
value={gist}
|
||||||
onValueChange={setGist}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("gist", value);
|
||||||
|
}}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let newValue = e.clipboardData.getData("text");
|
|
||||||
// if the user pasted a full GitHub URL, extract Gist ID
|
// if the user pasted a full GitHub URL, extract Gist ID
|
||||||
if (newValue.endsWith("/")) {
|
onOptionChange(
|
||||||
newValue = newValue.slice(0, -1);
|
"gist",
|
||||||
}
|
pastedPathTail(e.clipboardData.getData("text"), 1),
|
||||||
const parts = newValue.split("/");
|
);
|
||||||
if (parts.length > 1) {
|
|
||||||
newValue = parts.slice(-1).join("/");
|
|
||||||
}
|
|
||||||
setGist(newValue);
|
|
||||||
}}
|
}}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated}
|
||||||
/>
|
/>
|
||||||
@@ -251,7 +212,7 @@ export function CustomizeStage({
|
|||||||
<a
|
<a
|
||||||
href="https://wakatime.com/"
|
href="https://wakatime.com/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="underline text-blue-900"
|
className="underline text-primary"
|
||||||
>
|
>
|
||||||
WakaTime
|
WakaTime
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@@ -260,7 +221,9 @@ export function CustomizeStage({
|
|||||||
}
|
}
|
||||||
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
|
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
|
||||||
value={wakatimeUser}
|
value={wakatimeUser}
|
||||||
onValueChange={setWakatimeUser}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("wakatimeUser", value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.STATS && (
|
{cardType === CardType.STATS && (
|
||||||
@@ -269,13 +232,17 @@ export function CustomizeStage({
|
|||||||
text="Show all available statistics."
|
text="Show all available statistics."
|
||||||
question="Show all stats?"
|
question="Show all stats?"
|
||||||
checked={showAllStats}
|
checked={showAllStats}
|
||||||
onCheckedChange={setShowAllStats}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("showAllStats", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.STATS && (
|
{cardType === CardType.STATS && (
|
||||||
<StatsRankSection
|
<StatsRankSection
|
||||||
selectedOption={selectedStatsRank}
|
selectedOption={selectedStatsRank}
|
||||||
onOptionChange={setSelectedStatsRank}
|
onOptionChange={(option) => {
|
||||||
|
onOptionChange("selectedStatsRank", option);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.STATS && (
|
{cardType === CardType.STATS && (
|
||||||
@@ -284,7 +251,9 @@ export function CustomizeStage({
|
|||||||
text="Show icons next to all stats."
|
text="Show icons next to all stats."
|
||||||
question="Show icons?"
|
question="Show icons?"
|
||||||
checked={showIcons}
|
checked={showIcons}
|
||||||
onCheckedChange={setShowIcons}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("showIcons", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.STATS && (
|
{cardType === CardType.STATS && (
|
||||||
@@ -293,19 +262,25 @@ export function CustomizeStage({
|
|||||||
text="Count total commits or just commits of the last 365 days."
|
text="Count total commits or just commits of the last 365 days."
|
||||||
question="Include all commits?"
|
question="Include all commits?"
|
||||||
checked={includeAllCommits}
|
checked={includeAllCommits}
|
||||||
onCheckedChange={setIncludeAllCommits}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("includeAllCommits", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.TOP_LANGS && (
|
{cardType === CardType.TOP_LANGS && (
|
||||||
<LanguagesLayoutSection
|
<LanguagesLayoutSection
|
||||||
selectedLanguageLayoutOption={selectedLanguagesLayout}
|
selectedLanguageLayoutOption={selectedLanguagesLayout}
|
||||||
onLanguageLayoutOptionChange={setSelectedLanguagesLayout}
|
onLanguageLayoutOptionChange={(option) => {
|
||||||
|
onOptionChange("selectedLanguagesLayout", option);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.WAKATIME && (
|
{cardType === CardType.WAKATIME && (
|
||||||
<WakatimeLayoutSection
|
<WakatimeLayoutSection
|
||||||
selectedOption={selectedWakatimeLayout}
|
selectedOption={selectedWakatimeLayout}
|
||||||
onOptionChange={setSelectedWakatimeLayout}
|
onOptionChange={(option) => {
|
||||||
|
onOptionChange("selectedWakatimeLayout", option);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(cardType === CardType.TOP_LANGS ||
|
{(cardType === CardType.TOP_LANGS ||
|
||||||
@@ -320,7 +295,9 @@ export function CustomizeStage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
value={langsCount}
|
value={langsCount}
|
||||||
onValueChange={setLangsCount}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("langsCount", value);
|
||||||
|
}}
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
/>
|
/>
|
||||||
@@ -331,7 +308,9 @@ export function CustomizeStage({
|
|||||||
text="Hide language percentages or bytes while keeping the selected layout visible."
|
text="Hide language percentages or bytes while keeping the selected layout visible."
|
||||||
question="Hide values?"
|
question="Hide values?"
|
||||||
checked={hideValues}
|
checked={hideValues}
|
||||||
onCheckedChange={setHideValues}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("hideValues", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.WAKATIME && (
|
{cardType === CardType.WAKATIME && (
|
||||||
@@ -340,7 +319,9 @@ export function CustomizeStage({
|
|||||||
text="Show time spent in hours or percentages."
|
text="Show time spent in hours or percentages."
|
||||||
question="Show percentages?"
|
question="Show percentages?"
|
||||||
checked={usePercent}
|
checked={usePercent}
|
||||||
onCheckedChange={setUsePercent}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("usePercent", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(cardType === CardType.STATS ||
|
{(cardType === CardType.STATS ||
|
||||||
@@ -351,7 +332,9 @@ export function CustomizeStage({
|
|||||||
text="Shows a title at the top of the card."
|
text="Shows a title at the top of the card."
|
||||||
question="Show title?"
|
question="Show title?"
|
||||||
checked={showTitle}
|
checked={showTitle}
|
||||||
onCheckedChange={setShowTitle}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("showTitle", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
|
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
|
||||||
@@ -366,7 +349,9 @@ export function CustomizeStage({
|
|||||||
}
|
}
|
||||||
placeholder='e.g. "My GitHub Stats"'
|
placeholder='e.g. "My GitHub Stats"'
|
||||||
value={customTitle}
|
value={customTitle}
|
||||||
onValueChange={setCustomTitle}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("customTitle", value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(cardType === CardType.STATS ||
|
{(cardType === CardType.STATS ||
|
||||||
@@ -377,7 +362,9 @@ export function CustomizeStage({
|
|||||||
// text="Enable Animations."
|
// text="Enable Animations."
|
||||||
question="enable animations?"
|
question="enable animations?"
|
||||||
checked={enableAnimations}
|
checked={enableAnimations}
|
||||||
onCheckedChange={setEnableAnimations}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("enableAnimations", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(cardType === CardType.PIN || cardType === CardType.GIST) && (
|
{(cardType === CardType.PIN || cardType === CardType.GIST) && (
|
||||||
@@ -386,7 +373,9 @@ export function CustomizeStage({
|
|||||||
text="Shows the repo owner's name next to the repo name."
|
text="Shows the repo owner's name next to the repo name."
|
||||||
question="Show owner?"
|
question="Show owner?"
|
||||||
checked={showOwner}
|
checked={showOwner}
|
||||||
onCheckedChange={setShowOwner}
|
onCheckedChange={(checked) => {
|
||||||
|
onOptionChange("showOwner", checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cardType === CardType.PIN && (
|
{cardType === CardType.PIN && (
|
||||||
@@ -401,7 +390,9 @@ export function CustomizeStage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
value={descriptionLines}
|
value={descriptionLines}
|
||||||
onValueChange={setDescriptionLines}
|
onValueChange={(value) => {
|
||||||
|
onOptionChange("descriptionLines", value);
|
||||||
|
}}
|
||||||
min={1}
|
min={1}
|
||||||
max={3}
|
max={3}
|
||||||
/>
|
/>
|
||||||
@@ -411,7 +402,7 @@ export function CustomizeStage({
|
|||||||
<a
|
<a
|
||||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md"
|
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="underline text-blue-900"
|
className="underline text-primary"
|
||||||
>
|
>
|
||||||
customization documentation
|
customization documentation
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@@ -420,7 +411,7 @@ export function CustomizeStage({
|
|||||||
</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 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">
|
||||||
<CardImage imageSrc={fullSuffix} stage={2} />
|
<CardImage card={card} stage={2} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
import { clsx } from "clsx";
|
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { saveSvgAsPng } from "save-svg-as-png";
|
import { saveSvgAsPng } from "save-svg-as-png";
|
||||||
|
|
||||||
import { CardImage } from "../../../components/Card/CardImage";
|
import { CardImage } from "../../../components/Card/CardImage";
|
||||||
|
import { getCardThemeBackdrop } from "../../../components/Card/themeBackdrop";
|
||||||
import { Button } from "../../../components/Generic/Button";
|
import { Button } from "../../../components/Generic/Button";
|
||||||
import { HOST } from "../../../constants";
|
import { HOST } from "../../../constants";
|
||||||
|
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||||
|
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||||
|
|
||||||
interface DisplayStageProps {
|
interface DisplayStageProps {
|
||||||
filename: string;
|
filename: string;
|
||||||
link: string;
|
link: string;
|
||||||
themeSuffix: string;
|
theme: string;
|
||||||
|
card: CardUrlBuilder;
|
||||||
guestHint: string | null;
|
guestHint: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DisplayStage({
|
export function DisplayStage({
|
||||||
filename,
|
filename,
|
||||||
link,
|
link,
|
||||||
themeSuffix,
|
theme,
|
||||||
|
card,
|
||||||
guestHint,
|
guestHint,
|
||||||
}: DisplayStageProps): JSX.Element {
|
}: DisplayStageProps): JSX.Element {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
const downloadPNG = () => {
|
const downloadPNG = () => {
|
||||||
saveSvgAsPng(
|
saveSvgAsPng(
|
||||||
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
|
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
|
||||||
@@ -34,7 +40,7 @@ export function DisplayStage({
|
|||||||
|
|
||||||
const copyMarkdown = () => {
|
const copyMarkdown = () => {
|
||||||
void navigator.clipboard.writeText(
|
void navigator.clipboard.writeText(
|
||||||
`[](${link})`,
|
`[})](${link})`,
|
||||||
);
|
);
|
||||||
toast.info("Copied to Clipboard!", {
|
toast.info("Copied to Clipboard!", {
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
@@ -47,7 +53,7 @@ export function DisplayStage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyUrl = () => {
|
const copyUrl = () => {
|
||||||
void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
|
void navigator.clipboard.writeText(card.toApiUrl(HOST));
|
||||||
toast.info("Copied to Clipboard!", {
|
toast.info("Copied to Clipboard!", {
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
autoClose: 1500,
|
autoClose: 1500,
|
||||||
@@ -61,7 +67,7 @@ export function DisplayStage({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-wrap">
|
<div className="w-full flex flex-wrap">
|
||||||
<div className="h-auto lg:w-2/5 md:w-1/2">
|
<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">
|
<div className="flex flex-col items-center">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -82,10 +88,8 @@ export function DisplayStage({
|
|||||||
].map((item) => (
|
].map((item) => (
|
||||||
<Button
|
<Button
|
||||||
key={item.title}
|
key={item.title}
|
||||||
className={clsx("m-4 w-60 flex justify-center", {
|
variant={item.highlight ? "primary" : "soft"}
|
||||||
"bg-blue-500 hover:bg-blue-600 text-white": item.highlight,
|
className="m-4 w-60 flex justify-center"
|
||||||
"bg-white hover:bg-gray-100 text-black": !item.highlight,
|
|
||||||
})}
|
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -96,10 +100,14 @@ export function DisplayStage({
|
|||||||
</div>
|
</div>
|
||||||
</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 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
|
<CardImage
|
||||||
imageSrc={`${themeSuffix}&disable_animations=true`}
|
card={card.disableAnimations()}
|
||||||
stage={4}
|
stage={4}
|
||||||
|
className="flex justify-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function LoginAccountDeleteModal(
|
|||||||
<div className="fixed left-0 top-0 w-full h-full">
|
<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-full h-full flex justify-center items-center">
|
||||||
<div
|
<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}
|
ref={wrapperRef}
|
||||||
>
|
>
|
||||||
<p className="mb-1 text-2xl">Delete Account</p>
|
<p className="mb-1 text-2xl">Delete Account</p>
|
||||||
@@ -57,16 +57,10 @@ export function LoginAccountDeleteModal(
|
|||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
<Button
|
<Button variant="primary" onClick={onClose}>
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="error" className="ml-auto" onClick={onConfirm}>
|
||||||
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
Delete Account
|
Delete Account
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,14 +56,17 @@ export function LoginAccountManagement(): JSX.Element {
|
|||||||
<a
|
<a
|
||||||
href={`https://${HOST}/api/downgrade?user_key=${userKey as string}`}
|
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" />
|
<GithubIcon className="w-6 h-6" />
|
||||||
<span className="ml-2 xl:text-lg">
|
<span className="ml-2 xl:text-lg">
|
||||||
Downgrade to Public Access
|
Downgrade to Public Access
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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
|
Switch to public access if you prefer not to share private
|
||||||
contributions.
|
contributions.
|
||||||
</p>
|
</p>
|
||||||
@@ -71,14 +74,17 @@ export function LoginAccountManagement(): JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
<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" />
|
<GithubIcon className="w-6 h-6" />
|
||||||
<span className="ml-2 xl:text-lg">
|
<span className="ml-2 xl:text-lg">
|
||||||
Upgrade to Private Access
|
Upgrade to Private Access
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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
|
Upgrade to include contributions in private repositories for more
|
||||||
complete and accurate stats.
|
complete and accurate stats.
|
||||||
</p>
|
</p>
|
||||||
@@ -89,12 +95,15 @@ export function LoginAccountManagement(): JSX.Element {
|
|||||||
{/* Delete Account Button */}
|
{/* Delete Account Button */}
|
||||||
<div className="mt-6 flex items-center gap-4">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button
|
<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}
|
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>
|
</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
|
This will delete your GitHub-Stats-Extended account and then redirect
|
||||||
you to a GitHub screen where you can revoke your access token.
|
you to a GitHub screen where you can revoke your access token.
|
||||||
</p>
|
</p>
|
||||||
@@ -103,12 +112,13 @@ export function LoginAccountManagement(): JSX.Element {
|
|||||||
{/* Logout Button */}
|
{/* Logout Button */}
|
||||||
<div className="mt-6 flex items-center gap-4">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button
|
<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}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
<span className="xl:text-lg">Log Out</span>
|
<span className="xl:text-lg">Log Out</span>
|
||||||
</Button>
|
</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.
|
Log out from GitHub-Stats-Extended.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function LoginBox(props: LoginBoxProps): JSX.Element {
|
|||||||
<div className="h-full flex flex-wrap">
|
<div className="h-full flex flex-wrap">
|
||||||
<div className={clsx("md:flex", { "opacity-25": isOpaque })}>
|
<div className={clsx("md:flex", { "opacity-25": isOpaque })}>
|
||||||
<div className="lg:block lg:w-3/5 lg:p-8">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,23 +7,26 @@ import {
|
|||||||
DEMO_USER,
|
DEMO_USER,
|
||||||
DEMO_WAKATIME_USER,
|
DEMO_WAKATIME_USER,
|
||||||
} from "../../../../constants";
|
} from "../../../../constants";
|
||||||
|
import { CardType } from "../../../../models/CardType";
|
||||||
|
import { cardUrl } from "../../../../models/CardUrl";
|
||||||
|
import { useTheme } from "../../../../redux/selectors/themeSelectors";
|
||||||
|
|
||||||
const cards: Array<{ demoImageSrc: string }> = [
|
const cards = [
|
||||||
{
|
cardUrl(CardType.PIN).repo(DEMO_REPO).disableAnimations(),
|
||||||
demoImageSrc: `/pin?repo=${DEMO_REPO}&disable_animations=true`,
|
cardUrl(CardType.TOP_LANGS)
|
||||||
},
|
.username(DEMO_USER)
|
||||||
{
|
.langsCount(4)
|
||||||
demoImageSrc: `/top-langs?username=${DEMO_USER}&langs_count=4&disable_animations=true`,
|
.disableAnimations(),
|
||||||
},
|
cardUrl(CardType.STATS)
|
||||||
{
|
.username(DEMO_USER)
|
||||||
demoImageSrc: `?username=${DEMO_USER}&include_all_commits=true&disable_animations=true`,
|
.includeAllCommits()
|
||||||
},
|
.disableAnimations(),
|
||||||
{
|
cardUrl(CardType.WAKATIME)
|
||||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450&disable_animations=true`,
|
.username(DEMO_WAKATIME_USER)
|
||||||
},
|
.langsCount(6)
|
||||||
{
|
.cardWidth(450)
|
||||||
demoImageSrc: `/gist?id=${DEMO_GIST}&disable_animations=true`,
|
.disableAnimations(),
|
||||||
},
|
cardUrl(CardType.GIST).gistId(DEMO_GIST).disableAnimations(),
|
||||||
];
|
];
|
||||||
|
|
||||||
function getCardXPosition(cardIndex: number): number {
|
function getCardXPosition(cardIndex: number): number {
|
||||||
@@ -39,22 +42,28 @@ function getCardXPosition(cardIndex: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginBoxDemoCards(): JSX.Element {
|
export function LoginBoxDemoCards(): JSX.Element {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full lg:w-2/5 flex lg:flex-col lg:p-8 relative overflow-hidden">
|
<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">
|
<div className="relative w-full h-full">
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => {
|
||||||
<div
|
// Show dark-themed demo cards in dark mode so they fit the surroundings.
|
||||||
key={card.demoImageSrc}
|
const demoCard = isDark ? card.theme("github_dark") : card;
|
||||||
style={{
|
return (
|
||||||
left: `${getCardXPosition(index)}%`,
|
<div
|
||||||
position: "relative",
|
key={demoCard.toString()}
|
||||||
zoom: "0.5",
|
style={{
|
||||||
marginBottom: "1%",
|
left: `${getCardXPosition(index)}%`,
|
||||||
}}
|
position: "relative",
|
||||||
>
|
zoom: "0.5",
|
||||||
<CardImage imageSrc={card.demoImageSrc} compact={false} stage={0} />
|
marginBottom: "1%",
|
||||||
</div>
|
}}
|
||||||
))}
|
>
|
||||||
|
<CardImage card={demoCard} compact={false} stage={0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,24 +20,30 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
|||||||
<LoginBox>
|
<LoginBox>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<a href={GITHUB_PUBLIC_AUTH_URL}>
|
<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" />
|
<GithubIcon className="w-6 h-6" />
|
||||||
<span className="ml-2 xl:text-lg">GitHub Public Access</span>
|
GitHub Public Access
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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.
|
Generate stats based on your contributions in public repositories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
<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" />
|
<GithubIcon className="w-6 h-6" />
|
||||||
<span className="ml-2 xl:text-lg">GitHub Private Access</span>
|
GitHub Private Access
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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
|
Include contributions from private repositories for more complete and
|
||||||
accurate stats.
|
accurate stats.
|
||||||
</p>
|
</p>
|
||||||
@@ -45,12 +51,13 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
|||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<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}
|
onClick={onContinueAsGuest}
|
||||||
>
|
>
|
||||||
<span className="ml-2 xl:text-lg">Continue as Guest</span>
|
Continue as Guest
|
||||||
</Button>
|
</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
|
Explore options using sample data. Insert your own username in the
|
||||||
last step.
|
last step.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
DEMO_WAKATIME_USER,
|
DEMO_WAKATIME_USER,
|
||||||
} from "../../../constants";
|
} from "../../../constants";
|
||||||
import { CardType } from "../../../models/CardType";
|
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";
|
import { useUserId } from "../../../redux/selectors/userSelectors";
|
||||||
|
|
||||||
interface SelectCardStageProps {
|
interface SelectCardStageProps {
|
||||||
@@ -21,12 +24,13 @@ export function SelectCardStage({
|
|||||||
onCardTypeChange,
|
onCardTypeChange,
|
||||||
}: SelectCardStageProps): JSX.Element {
|
}: SelectCardStageProps): JSX.Element {
|
||||||
const userId = useUserId(DEMO_USER);
|
const userId = useUserId(DEMO_USER);
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
const options = useMemo<
|
const options = useMemo<
|
||||||
Array<{
|
Array<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
demoImageSrc: string;
|
demoCard: CardUrlBuilder;
|
||||||
cardType: CardType;
|
cardType: CardType;
|
||||||
}>
|
}>
|
||||||
>(
|
>(
|
||||||
@@ -34,33 +38,36 @@ export function SelectCardStage({
|
|||||||
{
|
{
|
||||||
title: "GitHub Stats Card",
|
title: "GitHub Stats Card",
|
||||||
description: "your overall GitHub statistics",
|
description: "your overall GitHub statistics",
|
||||||
demoImageSrc: `?username=${userId}&include_all_commits=true`,
|
demoCard: cardUrl(CardType.STATS).username(userId).includeAllCommits(),
|
||||||
cardType: CardType.STATS,
|
cardType: CardType.STATS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Top Languages Card",
|
title: "Top Languages Card",
|
||||||
description: "your most frequently used languages",
|
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,
|
cardType: CardType.TOP_LANGS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "GitHub Extra Pin",
|
title: "GitHub Extra Pin",
|
||||||
description:
|
description:
|
||||||
"pin more than 6 repositories in your profile using a GitHub profile readme",
|
"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,
|
cardType: CardType.PIN,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "GitHub Gist Pin",
|
title: "GitHub Gist Pin",
|
||||||
description:
|
description:
|
||||||
"pin gists in your GitHub profile using a GitHub profile readme",
|
"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,
|
cardType: CardType.GIST,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "WakaTime Stats Card",
|
title: "WakaTime Stats Card",
|
||||||
description: "your coding activity from WakaTime",
|
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,
|
cardType: CardType.WAKATIME,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -69,25 +76,31 @@ export function SelectCardStage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-wrap">
|
<div className="w-full flex flex-wrap">
|
||||||
{options.map((card) => (
|
{options.map((card) => {
|
||||||
<button
|
// Show dark-themed demo cards in dark mode so they fit the surroundings.
|
||||||
className="p-2 lg:p-4"
|
const demoCard = isDark
|
||||||
key={card.cardType}
|
? card.demoCard.theme("github_dark")
|
||||||
type="button"
|
: card.demoCard;
|
||||||
onClick={() => {
|
return (
|
||||||
onCardTypeChange(card.cardType);
|
<button
|
||||||
}}
|
className="p-2 lg:p-4"
|
||||||
>
|
key={card.cardType}
|
||||||
<Card
|
type="button"
|
||||||
title={card.title}
|
onClick={() => {
|
||||||
description={card.description}
|
onCardTypeChange(card.cardType);
|
||||||
imageSrc={card.demoImageSrc}
|
}}
|
||||||
selected={selectedCardType === card.cardType}
|
>
|
||||||
fixedSize
|
<Card
|
||||||
stage={1}
|
title={card.title}
|
||||||
/>
|
description={card.description}
|
||||||
</button>
|
card={demoCard}
|
||||||
))}
|
selected={selectedCardType === card.cardType}
|
||||||
|
fixedSize
|
||||||
|
stage={1}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { themes } from "@stats-organization/github-readme-stats-core";
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
import { Card } from "../../../components/Card/Card";
|
import { Card } from "../../../components/Card/Card";
|
||||||
|
import {
|
||||||
|
getCardThemeBackdrop,
|
||||||
|
getThemeSortRank,
|
||||||
|
} from "../../../components/Card/themeBackdrop";
|
||||||
|
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||||
|
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||||
|
|
||||||
const excludedThemes = [
|
const excludedThemes = [
|
||||||
"merko",
|
"merko",
|
||||||
@@ -12,53 +18,65 @@ const excludedThemes = [
|
|||||||
"holi",
|
"holi",
|
||||||
];
|
];
|
||||||
|
|
||||||
const themeList = Object.keys(themes).filter(
|
// Light themes first, adaptive themes in the middle, dark themes last.
|
||||||
(myTheme) => !excludedThemes.includes(myTheme),
|
const themeList = Object.keys(themes)
|
||||||
);
|
.filter((myTheme) => !excludedThemes.includes(myTheme))
|
||||||
|
.sort((a, b) => getThemeSortRank(a) - getThemeSortRank(b));
|
||||||
|
|
||||||
interface ThemeStageProps {
|
interface ThemeStageProps {
|
||||||
fullSuffix: string;
|
card: CardUrlBuilder;
|
||||||
theme: string;
|
theme: string;
|
||||||
onThemeChange: (theme: string) => void;
|
onThemeChange: (theme: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeStage({
|
export function ThemeStage({
|
||||||
theme,
|
theme,
|
||||||
fullSuffix,
|
card,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
}: ThemeStageProps): JSX.Element {
|
}: ThemeStageProps): JSX.Element {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
{themeList.map((myTheme) => (
|
{themeList.map((myTheme) => {
|
||||||
<button
|
const themeColors = themes[myTheme as keyof typeof themes];
|
||||||
className="p-2 lg:p-4"
|
return (
|
||||||
key={myTheme}
|
<button
|
||||||
type="button"
|
className="p-2 lg:p-4"
|
||||||
onClick={() => {
|
key={myTheme}
|
||||||
onThemeChange(myTheme);
|
type="button"
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
onThemeChange(myTheme);
|
||||||
<Card
|
}}
|
||||||
title={myTheme}
|
>
|
||||||
description=""
|
<Card
|
||||||
imageSrc={`${fullSuffix}&theme=${myTheme}`}
|
title={myTheme}
|
||||||
selected={theme === myTheme}
|
description=""
|
||||||
stage={3}
|
card={card.theme(myTheme)}
|
||||||
/>
|
selected={theme === myTheme}
|
||||||
</button>
|
stage={3}
|
||||||
))}
|
backgroundColor={getCardThemeBackdrop(myTheme, isDark)}
|
||||||
|
titleColor={`#${
|
||||||
|
myTheme === "ambient_gradient" && !isDark
|
||||||
|
? (themes["ambient_gradient"].bg_color.split(",")[1] ?? "")
|
||||||
|
: themeColors.title_color
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-10 pr-10">
|
<div className="pl-10 pr-10">
|
||||||
For more theme options check the{" "}
|
{"For more theme options check the "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md#themes"
|
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md#themes"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="underline text-blue-900"
|
className="underline text-primary"
|
||||||
>
|
>
|
||||||
customization documentation
|
customization documentation
|
||||||
</a>{" "}
|
</a>
|
||||||
after you copied your card URL in step 5.
|
{" after you copied your card URL in step 5."}
|
||||||
</div>
|
</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 { USE_LOGGER } from "../constants";
|
||||||
|
|
||||||
import { loggerMiddleware } from "./logger";
|
import { loggerMiddleware } from "./logger";
|
||||||
|
import theme from "./slices/theme";
|
||||||
import user from "./slices/user";
|
import user from "./slices/user";
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
user,
|
user,
|
||||||
|
theme,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) => {
|
middleware: (getDefaultMiddleware) => {
|
||||||
const middleware = getDefaultMiddleware();
|
const middleware = getDefaultMiddleware();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
> By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, [allow GitHub-Stats-Extended to access your private contributions](fork.md#private-contributions-support) or [deploy your own instance](deploy.md).
|
> By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, [allow GitHub-Stats-Extended to access your private contributions](fork.md#private-contributions-support) or [deploy your own instance](deploy.md).
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [src/calculateRank.js](https://github.com/stats-organization/github-stats-extended/blob/master/backend/src/calculateRank.js). The circle around the rank shows 100 minus the global percentile.
|
> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [calculateRank.ts](https://github.com/stats-organization/github-stats-extended/blob/master/packages/core/src/calculateRank.ts). The circle around the rank shows 100 minus the global percentile.
|
||||||
|
|
||||||
### Hiding individual stats
|
### Hiding individual stats
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ GitHub Stats Extended comes with several built-in themes (e.g. `dark`, `radical`
|
|||||||
|
|
||||||
<img src="https://res.cloudinary.com/anuraghazra/image/upload/v1595174536/grs-themes_l4ynja.png" alt="GitHub Stats Extended Themes" width="600px"/>
|
<img src="https://res.cloudinary.com/anuraghazra/image/upload/v1595174536/grs-themes_l4ynja.png" alt="GitHub Stats Extended Themes" width="600px"/>
|
||||||
|
|
||||||
You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.js). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed.
|
You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.ts). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed.
|
||||||
|
|
||||||
#### Responsive Card Theme
|
#### Responsive Card Theme
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ Selecting the right scopes for your token is important in case you want to displ
|
|||||||
|
|
||||||
Click on the deploy button to get started!
|
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)
|
||||||
|
|
||||||
<b>Recommended: Step-by-step guide on setting up your own Vercel instance</b>
|
<b>Recommended: Step-by-step guide on setting up your own Vercel instance</b>
|
||||||
|
|
||||||
|
|||||||
+11
-29
@@ -2,12 +2,14 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import { includeIgnoreFile } from "@eslint/compat";
|
import { includeIgnoreFile } from "@eslint/compat";
|
||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
|
import eslintReact from "@eslint-react/eslint-plugin";
|
||||||
import { defineConfig } from "eslint/config";
|
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 { importX } from "eslint-plugin-import-x";
|
||||||
import { default as jsdoc } from "eslint-plugin-jsdoc";
|
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 globals from "globals";
|
||||||
import { default as tseslint } from "typescript-eslint";
|
import { default as tseslint } from "typescript-eslint";
|
||||||
|
|
||||||
@@ -26,14 +28,7 @@ export default defineConfig(
|
|||||||
/** Keep in sync with `tsconfig.base.json#customConditions` */
|
/** Keep in sync with `tsconfig.base.json#customConditions` */
|
||||||
"@stats/source",
|
"@stats/source",
|
||||||
|
|
||||||
"types",
|
...defaultConditionNames,
|
||||||
"import",
|
|
||||||
|
|
||||||
"require",
|
|
||||||
"node",
|
|
||||||
"node-addons",
|
|
||||||
"browser",
|
|
||||||
"default",
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -150,6 +145,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
|
// We don't need this we have typescript
|
||||||
"jsdoc/require-returns": "off",
|
"jsdoc/require-returns": "off",
|
||||||
"jsdoc/require-returns-description": "off",
|
"jsdoc/require-returns-description": "off",
|
||||||
@@ -172,23 +171,6 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"],
|
files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"],
|
||||||
plugins: {
|
...eslintReact.configs["recommended-typescript"],
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-14
@@ -2,27 +2,26 @@
|
|||||||
"name": "@stats-organization/root",
|
"name": "@stats-organization/root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint-react/eslint-plugin": "5.17.1",
|
||||||
"@eslint/compat": "2.1.0",
|
"@eslint/compat": "2.1.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.60.0",
|
"@playwright/test": "1.61.1",
|
||||||
"@types/node": "24.13.1",
|
"@types/node": "24.13.3",
|
||||||
"@vitest/coverage-v8": "catalog:default",
|
"@vitest/coverage-v8": "catalog:default",
|
||||||
"eslint": "10.4.1",
|
"eslint": "10.7.0",
|
||||||
"eslint-import-resolver-typescript": "4.4.5",
|
"eslint-import-resolver-typescript": "4.4.5",
|
||||||
"eslint-plugin-import-x": "4.16.2",
|
"eslint-plugin-import-x": "4.17.1",
|
||||||
"eslint-plugin-jsdoc": "63.0.2",
|
"eslint-plugin-jsdoc": "63.1.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"globals": "17.7.0",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
|
||||||
"globals": "17.6.0",
|
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"knip": "6.16.1",
|
"knip": "6.27.0",
|
||||||
"lint-staged": "17.0.7",
|
"lint-staged": "17.0.8",
|
||||||
"prettier": "3.8.4",
|
"prettier": "3.9.5",
|
||||||
"turbo": "2.9.17",
|
"turbo": "2.10.5",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.61.0",
|
"typescript-eslint": "8.64.0",
|
||||||
"vitest": "catalog:default"
|
"vitest": "catalog:default"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@stats-organization/github-readme-stats-core",
|
"name": "@stats-organization/github-readme-stats-core",
|
||||||
"version": "2.1.2",
|
"version": "2.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@uppercod/css-to-object": "1.1.1",
|
"@uppercod/css-to-object": "1.1.1",
|
||||||
"axios-mock-adapter": "2.1.0",
|
"axios-mock-adapter": "2.1.0",
|
||||||
"js-yaml": "4.2.0",
|
"js-yaml": "5.2.1",
|
||||||
"jsdom": "catalog:default",
|
"jsdom": "catalog:default",
|
||||||
"vitest": "catalog:default"
|
"vitest": "catalog:default"
|
||||||
},
|
},
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { renderGistCard } from "../cards/gist.js";
|
import { renderGistCard } from "../cards/gist.js";
|
||||||
|
import { findInvalidColor } from "../common/color.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
@@ -28,6 +27,23 @@ export default async (
|
|||||||
},
|
},
|
||||||
pat = null,
|
pat = null,
|
||||||
) => {
|
) => {
|
||||||
|
const invalidColorInput = findInvalidColor({
|
||||||
|
title_color,
|
||||||
|
icon_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
});
|
||||||
|
if (invalidColorInput) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
@@ -45,6 +61,24 @@ export default async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gistData = await fetchGist(id, pat);
|
const gistData = await fetchGist(id, pat);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { renderStatsCard } from "../cards/stats.js";
|
import { renderStatsCard } from "../cards/stats.js";
|
||||||
|
import { findInvalidColor } from "../common/color.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
@@ -46,6 +45,24 @@ export default async (
|
|||||||
},
|
},
|
||||||
pat = null,
|
pat = null,
|
||||||
) => {
|
) => {
|
||||||
|
const invalidColorInput = findInvalidColor({
|
||||||
|
title_color,
|
||||||
|
ring_color,
|
||||||
|
icon_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
});
|
||||||
|
if (invalidColorInput) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { renderRepoCard } from "../cards/repo.js";
|
import { renderRepoCard } from "../cards/repo.js";
|
||||||
|
import { findInvalidColor } from "../common/color.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
@@ -36,6 +35,23 @@ export default async (
|
|||||||
},
|
},
|
||||||
pat = null,
|
pat = null,
|
||||||
) => {
|
) => {
|
||||||
|
const invalidColorInput = findInvalidColor({
|
||||||
|
title_color,
|
||||||
|
icon_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
});
|
||||||
|
if (invalidColorInput) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { renderTopLanguages } from "../cards/top-languages.js";
|
import { renderTopLanguages } from "../cards/top-languages.js";
|
||||||
|
import { findInvalidColor } from "../common/color.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
@@ -40,6 +39,23 @@ export default async (
|
|||||||
},
|
},
|
||||||
pat = null,
|
pat = null,
|
||||||
) => {
|
) => {
|
||||||
|
const invalidColorInput = findInvalidColor({
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
prog_bar_bg_color,
|
||||||
|
border_color,
|
||||||
|
});
|
||||||
|
if (invalidColorInput) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
@@ -57,10 +73,27 @@ export default async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safePattern = /^[-\w/.,]+$/;
|
||||||
|
if (username && !safePattern.test(username)) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: "Username contains unsafe characters",
|
||||||
|
renderOptions: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
layout !== undefined &&
|
layout !== undefined &&
|
||||||
(typeof layout !== "string" ||
|
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)
|
||||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
@@ -80,8 +113,7 @@ export default async (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
stats_format !== undefined &&
|
stats_format !== undefined &&
|
||||||
(typeof stats_format !== "string" ||
|
!["bytes", "percentages"].includes(stats_format)
|
||||||
!["bytes", "percentages"].includes(stats_format))
|
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { renderWakatimeCard } from "../cards/wakatime.js";
|
import { renderWakatimeCard } from "../cards/wakatime.js";
|
||||||
|
import { findInvalidColor } from "../common/color.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
@@ -34,6 +33,23 @@ export default async ({
|
|||||||
display_format,
|
display_format,
|
||||||
disable_animations,
|
disable_animations,
|
||||||
}) => {
|
}) => {
|
||||||
|
const invalidColorInput = findInvalidColor({
|
||||||
|
title_color,
|
||||||
|
icon_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
});
|
||||||
|
if (invalidColorInput) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return {
|
||||||
status: "error - permanent",
|
status: "error - permanent",
|
||||||
@@ -51,6 +67,24 @@ export default async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safePattern = /^[-\w/.,]+$/;
|
||||||
|
if (username && !safePattern.test(username)) {
|
||||||
|
return {
|
||||||
|
status: "error - permanent",
|
||||||
|
content: renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: "Username contains unsafe characters",
|
||||||
|
renderOptions: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fetchWakatimeStats({ username, api_domain });
|
const stats = await fetchWakatimeStats({ username, api_domain });
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Calculates the exponential cdf.
|
* Calculates the exponential cdf.
|
||||||
*
|
*
|
||||||
* @param {number} x The value.
|
* @param x The value.
|
||||||
* @returns {number} The exponential cdf.
|
* @returns The exponential cdf.
|
||||||
*/
|
*/
|
||||||
function exponential_cdf(x) {
|
function exponential_cdf(x: number): number {
|
||||||
return 1 - 2 ** -x;
|
return 1 - 2 ** -x;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the log normal cdf.
|
* Calculates the log normal cdf.
|
||||||
*
|
*
|
||||||
* @param {number} x The value.
|
* @param x The value.
|
||||||
* @returns {number} The log normal cdf.
|
* @returns The log normal cdf.
|
||||||
*/
|
*/
|
||||||
function log_normal_cdf(x) {
|
function log_normal_cdf(x: number): number {
|
||||||
// approximation
|
// approximation
|
||||||
return x / (1 + x);
|
return x / (1 + x);
|
||||||
}
|
}
|
||||||
@@ -22,16 +22,16 @@ function log_normal_cdf(x) {
|
|||||||
/**
|
/**
|
||||||
* Calculates the users rank.
|
* Calculates the users rank.
|
||||||
*
|
*
|
||||||
* @param {object} params Parameters on which the user's rank depends.
|
* @param params Parameters on which the user's rank depends.
|
||||||
* @param {boolean} params.all_commits Whether `include_all_commits` was used.
|
* @param params.all_commits Whether `include_all_commits` was used.
|
||||||
* @param {number} params.commits Number of commits.
|
* @param params.commits Number of commits.
|
||||||
* @param {number} params.prs The number of pull requests.
|
* @param params.prs The number of pull requests.
|
||||||
* @param {number} params.issues The number of issues.
|
* @param params.issues The number of issues.
|
||||||
* @param {number} params.reviews The number of reviews.
|
* @param params.reviews The number of reviews.
|
||||||
* @param {number} params.repos Total number of repos.
|
* @param params.repos Total number of repos (accepted for compatibility, unused in the calculation).
|
||||||
* @param {number} params.stars The number of stars.
|
* @param params.stars The number of stars.
|
||||||
* @param {number} params.followers The number of followers.
|
* @param params.followers The number of followers.
|
||||||
* @returns {{ level: string, percentile: number }} The users rank.
|
* @returns The users rank.
|
||||||
*/
|
*/
|
||||||
function calculateRank({
|
function calculateRank({
|
||||||
all_commits,
|
all_commits,
|
||||||
@@ -39,11 +39,18 @@ function calculateRank({
|
|||||||
prs,
|
prs,
|
||||||
issues,
|
issues,
|
||||||
reviews,
|
reviews,
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
repos, // unused
|
|
||||||
stars,
|
stars,
|
||||||
followers,
|
followers,
|
||||||
}) {
|
}: {
|
||||||
|
all_commits: boolean;
|
||||||
|
commits: number;
|
||||||
|
prs: number;
|
||||||
|
issues: number;
|
||||||
|
reviews: number;
|
||||||
|
repos: number;
|
||||||
|
stars: number;
|
||||||
|
followers: number;
|
||||||
|
}): { level: string; percentile: number } {
|
||||||
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
|
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
|
||||||
COMMITS_WEIGHT = 2;
|
COMMITS_WEIGHT = 2;
|
||||||
const PRS_MEDIAN = 50,
|
const PRS_MEDIAN = 50,
|
||||||
@@ -79,6 +86,9 @@ function calculateRank({
|
|||||||
TOTAL_WEIGHT;
|
TOTAL_WEIGHT;
|
||||||
|
|
||||||
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
|
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
|
||||||
|
if (level === undefined) {
|
||||||
|
throw new Error("Unable to determine rank level");
|
||||||
|
}
|
||||||
|
|
||||||
return { level, percentile: rank * 100 };
|
return { level, percentile: rank * 100 };
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { default as Card } from "../common/Card.js";
|
import { default as Card } from "../common/Card.js";
|
||||||
import { getCardColors } from "../common/color.js";
|
import { getCardColors } from "../common/color.js";
|
||||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { Card } from "../common/Card.js";
|
import { Card } from "../common/Card.js";
|
||||||
import { I18n } from "../common/I18n.js";
|
import { I18n } from "../common/I18n.js";
|
||||||
import { getCardColors } from "../common/color.js";
|
import { getCardColors, isPrefixedHexColor } from "../common/color.js";
|
||||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||||
import { encodeHTML } from "../common/html.js";
|
import { encodeHTML } from "../common/html.js";
|
||||||
import { icons } from "../common/icons.js";
|
import { icons } from "../common/icons.js";
|
||||||
@@ -34,20 +32,29 @@ const DESCRIPTION_MAX_LINES = 3;
|
|||||||
* @param {string} textColor The color of the text.
|
* @param {string} textColor The color of the text.
|
||||||
* @returns {string} Wrapped repo description SVG object.
|
* @returns {string} Wrapped repo description SVG object.
|
||||||
*/
|
*/
|
||||||
const getBadgeSVG = (label, textColor, xOffset = 0) => `
|
const getBadgeSVG = (label, textColor, xOffset = 0) => {
|
||||||
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
if (!isPrefixedHexColor(textColor)) {
|
||||||
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
throw new Error(`Invalid text color: "${textColor}"`);
|
||||||
<text
|
}
|
||||||
x="23" y="-5"
|
if (!Number.isFinite(xOffset)) {
|
||||||
alignment-baseline="central"
|
throw new Error(`Invalid xOffset: "${xOffset}"`);
|
||||||
dominant-baseline="central"
|
}
|
||||||
text-anchor="middle"
|
|
||||||
fill="${textColor}"
|
return `
|
||||||
>
|
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
||||||
${label}
|
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
||||||
</text>
|
<text
|
||||||
</g>
|
x="23" y="-5"
|
||||||
`;
|
alignment-baseline="central"
|
||||||
|
dominant-baseline="central"
|
||||||
|
text-anchor="middle"
|
||||||
|
fill="${textColor}"
|
||||||
|
>
|
||||||
|
${encodeHTML(label)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
|
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
|
||||||
@@ -112,6 +119,7 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
|
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
|
||||||
|
const encodedUsername = encodeURIComponent(username);
|
||||||
const STATS = {};
|
const STATS = {};
|
||||||
if (show.includes("prs_authored")) {
|
if (show.includes("prs_authored")) {
|
||||||
STATS.prs_authored = {
|
STATS.prs_authored = {
|
||||||
@@ -119,7 +127,7 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
label: i18n.t("repocard.prs-authored"),
|
label: i18n.t("repocard.prs-authored"),
|
||||||
value: totalPRsAuthored,
|
value: totalPRsAuthored,
|
||||||
id: "prs_authored",
|
id: "prs_authored",
|
||||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=pullrequests`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("prs_commented")) {
|
if (show.includes("prs_commented")) {
|
||||||
@@ -128,7 +136,7 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
label: i18n.t("repocard.prs-commented"),
|
label: i18n.t("repocard.prs-commented"),
|
||||||
value: totalPRsCommented,
|
value: totalPRsCommented,
|
||||||
id: "prs_commented",
|
id: "prs_commented",
|
||||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("prs_reviewed")) {
|
if (show.includes("prs_reviewed")) {
|
||||||
@@ -137,7 +145,7 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
label: i18n.t("repocard.prs-reviewed"),
|
label: i18n.t("repocard.prs-reviewed"),
|
||||||
value: totalPRsReviewed,
|
value: totalPRsReviewed,
|
||||||
id: "prs_reviewed",
|
id: "prs_reviewed",
|
||||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("issues_authored")) {
|
if (show.includes("issues_authored")) {
|
||||||
@@ -146,7 +154,7 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
label: i18n.t("repocard.issues-authored"),
|
label: i18n.t("repocard.issues-authored"),
|
||||||
value: totalIssuesAuthored,
|
value: totalIssuesAuthored,
|
||||||
id: "issues_authored",
|
id: "issues_authored",
|
||||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=issues`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("issues_commented")) {
|
if (show.includes("issues_commented")) {
|
||||||
@@ -155,7 +163,7 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
label: i18n.t("repocard.issues-commented"),
|
label: i18n.t("repocard.issues-commented"),
|
||||||
value: totalIssuesCommented,
|
value: totalIssuesCommented,
|
||||||
id: "issues_commented",
|
id: "issues_commented",
|
||||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=issues`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { Card } from "../common/Card.js";
|
import { Card } from "../common/Card.js";
|
||||||
import { I18n } from "../common/I18n.js";
|
import { I18n } from "../common/I18n.js";
|
||||||
import { getCardColors } from "../common/color.js";
|
import { getCardColors } from "../common/color.js";
|
||||||
import { CustomError } from "../common/error.js";
|
import { CustomError } from "../common/error.js";
|
||||||
import { kFormatter } from "../common/fmt.js";
|
import { kFormatter } from "../common/fmt.js";
|
||||||
|
import { encodeHTML } from "../common/html.js";
|
||||||
import { icons, rankIcon } from "../common/icons.js";
|
import { icons, rankIcon } from "../common/icons.js";
|
||||||
import { buildSearchFilter, clampValue } from "../common/ops.js";
|
import { buildSearchFilter, clampValue } from "../common/ops.js";
|
||||||
import { flexLayout, measureText } from "../common/render.js";
|
import { flexLayout, measureText } from "../common/render.js";
|
||||||
@@ -54,8 +53,10 @@ const LONG_LOCALES = [
|
|||||||
/**
|
/**
|
||||||
* Create a stats card text item.
|
* Create a stats card text item.
|
||||||
*
|
*
|
||||||
|
* The caller must ensure that the passed `icon` and `link` are properly sanitized!
|
||||||
|
*
|
||||||
* @param {object} params Object that contains the createTextNode parameters.
|
* @param {object} params Object that contains the createTextNode parameters.
|
||||||
* @param {string} params.icon The icon to display.
|
* @param {string} params.icon The sanitized icon to display.
|
||||||
* @param {string} params.label The label to display.
|
* @param {string} params.label The label to display.
|
||||||
* @param {number} params.value The value to display.
|
* @param {number} params.value The value to display.
|
||||||
* @param {string} params.id The id of the stat.
|
* @param {string} params.id The id of the stat.
|
||||||
@@ -66,7 +67,7 @@ const LONG_LOCALES = [
|
|||||||
* @param {boolean} params.bold Whether to bold the label.
|
* @param {boolean} params.bold Whether to bold the label.
|
||||||
* @param {string} params.numberFormat The format of numbers on card.
|
* @param {string} params.numberFormat The format of numbers on card.
|
||||||
* @param {number=} params.numberPrecision The precision of numbers on card.
|
* @param {number=} params.numberPrecision The precision of numbers on card.
|
||||||
* @param {string} params.link Url to link to.
|
* @param {string} params.link Sanitized url to link to.
|
||||||
* @param {number} params.labelXOffset horizontal offset for label.
|
* @param {number} params.labelXOffset horizontal offset for label.
|
||||||
* @returns {string} The stats card text item SVG object.
|
* @returns {string} The stats card text item SVG object.
|
||||||
*/
|
*/
|
||||||
@@ -85,6 +86,16 @@ const createTextNode = ({
|
|||||||
link,
|
link,
|
||||||
labelXOffset = 25,
|
labelXOffset = 25,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!Number.isFinite(labelXOffset)) {
|
||||||
|
throw new Error(`Invalid labelXOffset: "${labelXOffset}"`);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(shiftValuePos)) {
|
||||||
|
throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(index)) {
|
||||||
|
throw new Error(`Invalid index: "${index}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const precision =
|
const precision =
|
||||||
typeof numberPrecision === "number" && !isNaN(numberPrecision)
|
typeof numberPrecision === "number" && !isNaN(numberPrecision)
|
||||||
? clampValue(numberPrecision, 0, 2)
|
? clampValue(numberPrecision, 0, 2)
|
||||||
@@ -111,7 +122,7 @@ const createTextNode = ({
|
|||||||
${iconSvg}
|
${iconSvg}
|
||||||
<text class="stat ${
|
<text class="stat ${
|
||||||
bold ? " bold" : "not_bold"
|
bold ? " bold" : "not_bold"
|
||||||
}" ${labelOffset} y="12.5">${label}:</text>
|
}" ${labelOffset} y="12.5">${encodeHTML(label)}:</text>
|
||||||
<text
|
<text
|
||||||
class="stat ${bold ? " bold" : "not_bold"}"
|
class="stat ${bold ? " bold" : "not_bold"}"
|
||||||
x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
|
x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
|
||||||
@@ -424,13 +435,14 @@ const renderStatsCard = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
|
let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
|
||||||
|
const encodedUsername = encodeURIComponent(username);
|
||||||
if (show.includes("prs_authored")) {
|
if (show.includes("prs_authored")) {
|
||||||
STATS.prs_authored = {
|
STATS.prs_authored = {
|
||||||
icon: icons.prs,
|
icon: icons.prs,
|
||||||
label: i18n.t("statcard.prs-authored"),
|
label: i18n.t("statcard.prs-authored"),
|
||||||
value: totalPRsAuthored,
|
value: totalPRsAuthored,
|
||||||
id: "prs_authored",
|
id: "prs_authored",
|
||||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=pullrequests`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("prs_commented")) {
|
if (show.includes("prs_commented")) {
|
||||||
@@ -439,7 +451,7 @@ const renderStatsCard = (
|
|||||||
label: i18n.t("statcard.prs-commented"),
|
label: i18n.t("statcard.prs-commented"),
|
||||||
value: totalPRsCommented,
|
value: totalPRsCommented,
|
||||||
id: "prs_commented",
|
id: "prs_commented",
|
||||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("prs_reviewed")) {
|
if (show.includes("prs_reviewed")) {
|
||||||
@@ -448,7 +460,7 @@ const renderStatsCard = (
|
|||||||
label: i18n.t("statcard.prs-reviewed"),
|
label: i18n.t("statcard.prs-reviewed"),
|
||||||
value: totalPRsReviewed,
|
value: totalPRsReviewed,
|
||||||
id: "prs_reviewed",
|
id: "prs_reviewed",
|
||||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("issues_authored")) {
|
if (show.includes("issues_authored")) {
|
||||||
@@ -457,7 +469,7 @@ const renderStatsCard = (
|
|||||||
label: i18n.t("statcard.issues-authored"),
|
label: i18n.t("statcard.issues-authored"),
|
||||||
value: totalIssuesAuthored,
|
value: totalIssuesAuthored,
|
||||||
id: "issues_authored",
|
id: "issues_authored",
|
||||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=issues`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (show.includes("issues_commented")) {
|
if (show.includes("issues_commented")) {
|
||||||
@@ -466,7 +478,7 @@ const renderStatsCard = (
|
|||||||
label: i18n.t("statcard.issues-commented"),
|
label: i18n.t("statcard.issues-commented"),
|
||||||
value: totalIssuesCommented,
|
value: totalIssuesCommented,
|
||||||
id: "issues_commented",
|
id: "issues_commented",
|
||||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=issues`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { Card } from "../common/Card.js";
|
import { Card } from "../common/Card.js";
|
||||||
import { I18n } from "../common/I18n.js";
|
import { I18n } from "../common/I18n.js";
|
||||||
import { fallbackColor, getCardColors } from "../common/color.js";
|
import {
|
||||||
|
fallbackColor,
|
||||||
|
getCardColors,
|
||||||
|
isPrefixedHexColor,
|
||||||
|
} from "../common/color.js";
|
||||||
import { formatBytes } from "../common/fmt.js";
|
import { formatBytes } from "../common/fmt.js";
|
||||||
|
import { encodeHTML } from "../common/html.js";
|
||||||
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
|
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
|
||||||
import {
|
import {
|
||||||
createProgressNode,
|
createProgressNode,
|
||||||
@@ -246,8 +249,8 @@ const createProgressTextNode = ({
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
|
<text data-testid="lang-name" x="2" y="15" class="lang-name">${encodeHTML(name)}</text>
|
||||||
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>`}
|
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${encodeHTML(displayValue)}</text>`}
|
||||||
${createProgressNode({
|
${createProgressNode({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 25,
|
y: 25,
|
||||||
@@ -287,11 +290,15 @@ const createCompactLangNode = ({
|
|||||||
const staggerDelay = (index + 3) * 150;
|
const staggerDelay = (index + 3) * 150;
|
||||||
const color = lang.color || "#858585";
|
const color = lang.color || "#858585";
|
||||||
|
|
||||||
|
if (!isPrefixedHexColor(color)) {
|
||||||
|
throw new Error(`Invalid language color: "${color}"`);
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||||
${lang.name} ${hideProgress || hideValues ? "" : displayValue}
|
${encodeHTML(lang.name)} ${hideProgress || hideValues ? "" : encodeHTML(displayValue)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
`;
|
`;
|
||||||
@@ -441,6 +448,11 @@ const renderCompactLayout = (
|
|||||||
let progressOffset = 0;
|
let progressOffset = 0;
|
||||||
const compactProgressBar = langs
|
const compactProgressBar = langs
|
||||||
.map((lang) => {
|
.map((lang) => {
|
||||||
|
const langColor = lang.color || DEFAULT_LANG_COLOR;
|
||||||
|
if (!isPrefixedHexColor(langColor)) {
|
||||||
|
throw new Error(`Invalid language color: "${langColor}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const percentage = parseFloat(
|
const percentage = parseFloat(
|
||||||
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
|
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
|
||||||
);
|
);
|
||||||
@@ -455,7 +467,7 @@ const renderCompactLayout = (
|
|||||||
y="0"
|
y="0"
|
||||||
width="${progress}"
|
width="${progress}"
|
||||||
height="8"
|
height="8"
|
||||||
fill="${lang.color || "#858585"}"
|
fill="${langColor}"
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
progressOffset += percentage;
|
progressOffset += percentage;
|
||||||
@@ -516,18 +528,23 @@ const renderDonutVerticalLayout = (
|
|||||||
|
|
||||||
// Generate each donut vertical chart part
|
// Generate each donut vertical chart part
|
||||||
for (const lang of langs) {
|
for (const lang of langs) {
|
||||||
|
const langColor = lang.color || DEFAULT_LANG_COLOR;
|
||||||
|
if (!isPrefixedHexColor(langColor)) {
|
||||||
|
throw new Error(`Invalid language color: "${langColor}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const percentage = (lang.size / totalLanguageSize) * 100;
|
const percentage = (lang.size / totalLanguageSize) * 100;
|
||||||
const circleLength = totalCircleLength * (percentage / 100);
|
const circleLength = totalCircleLength * (percentage / 100);
|
||||||
const delay = startDelayCoefficient * 100;
|
const delay = startDelayCoefficient * 100;
|
||||||
|
|
||||||
circles.push(`
|
circles.push(`
|
||||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||||
<circle
|
<circle
|
||||||
cx="150"
|
cx="150"
|
||||||
cy="100"
|
cy="100"
|
||||||
r="${radius}"
|
r="${radius}"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
stroke="${lang.color}"
|
stroke="${langColor}"
|
||||||
stroke-width="25"
|
stroke-width="25"
|
||||||
stroke-dasharray="${totalCircleLength}"
|
stroke-dasharray="${totalCircleLength}"
|
||||||
stroke-dashoffset="${indent}"
|
stroke-dashoffset="${indent}"
|
||||||
@@ -591,6 +608,11 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
|||||||
|
|
||||||
// Generate each pie chart part
|
// Generate each pie chart part
|
||||||
for (const lang of langs) {
|
for (const lang of langs) {
|
||||||
|
const langColor = lang.color || DEFAULT_LANG_COLOR;
|
||||||
|
if (!isPrefixedHexColor(langColor)) {
|
||||||
|
throw new Error(`Invalid language color: "${langColor}"`);
|
||||||
|
}
|
||||||
|
|
||||||
if (langs.length === 1) {
|
if (langs.length === 1) {
|
||||||
paths.push(`
|
paths.push(`
|
||||||
<circle
|
<circle
|
||||||
@@ -598,7 +620,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
|||||||
cy="${centerY}"
|
cy="${centerY}"
|
||||||
r="${radius}"
|
r="${radius}"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
fill="${lang.color}"
|
fill="${langColor}"
|
||||||
data-testid="lang-pie"
|
data-testid="lang-pie"
|
||||||
size="100"
|
size="100"
|
||||||
/>
|
/>
|
||||||
@@ -631,7 +653,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
|||||||
data-testid="lang-pie"
|
data-testid="lang-pie"
|
||||||
size="${percentage}"
|
size="${percentage}"
|
||||||
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
|
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
|
||||||
fill="${lang.color}"
|
fill="${langColor}"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
`);
|
`);
|
||||||
@@ -718,12 +740,22 @@ const renderDonutLayout = (
|
|||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
hideValues,
|
||||||
) => {
|
) => {
|
||||||
|
if (!Number.isFinite(width)) {
|
||||||
|
throw new Error(`Invalid width: "${width}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const centerX = width / 3;
|
const centerX = width / 3;
|
||||||
const centerY = width / 3;
|
const centerY = width / 3;
|
||||||
const radius = centerX - 60;
|
const radius = centerX - 60;
|
||||||
const strokeWidth = 12;
|
const strokeWidth = 12;
|
||||||
|
|
||||||
const colors = langs.map((lang) => lang.color);
|
const colors = langs.map((lang) => {
|
||||||
|
const langColor = lang.color || DEFAULT_LANG_COLOR;
|
||||||
|
if (!isPrefixedHexColor(langColor)) {
|
||||||
|
throw new Error(`Invalid language color: "${langColor}"`);
|
||||||
|
}
|
||||||
|
return langColor;
|
||||||
|
});
|
||||||
const langsPercents = langs.map((lang) =>
|
const langsPercents = langs.map((lang) =>
|
||||||
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
|
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
|
||||||
);
|
);
|
||||||
@@ -785,10 +817,14 @@ const renderDonutLayout = (
|
|||||||
* @returns {string} No languages data SVG node string.
|
* @returns {string} No languages data SVG node string.
|
||||||
*/
|
*/
|
||||||
const noLanguagesDataNode = ({ color, text, layout }) => {
|
const noLanguagesDataNode = ({ color, text, layout }) => {
|
||||||
|
if (!isPrefixedHexColor(color)) {
|
||||||
|
throw new Error(`Invalid text color: "${color}"`);
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<text x="${
|
<text x="${
|
||||||
layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
|
layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
|
||||||
}" y="11" class="stat bold" fill="${color}">${text}</text>
|
}" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}</text>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { Card } from "../common/Card.js";
|
import { Card } from "../common/Card.js";
|
||||||
import { I18n } from "../common/I18n.js";
|
import { I18n } from "../common/I18n.js";
|
||||||
import { getCardColors } from "../common/color.js";
|
import { getCardColors, isPrefixedHexColor } from "../common/color.js";
|
||||||
|
import { encodeHTML } from "../common/html.js";
|
||||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||||
import { clampValue, lowercaseTrim } from "../common/ops.js";
|
import { clampValue, lowercaseTrim } from "../common/ops.js";
|
||||||
import { createProgressNode, flexLayout } from "../common/render.js";
|
import { createProgressNode, flexLayout } from "../common/render.js";
|
||||||
@@ -26,8 +25,12 @@ const TOTAL_TEXT_WIDTH = 275;
|
|||||||
* @returns {string} No coding activity SVG node string.
|
* @returns {string} No coding activity SVG node string.
|
||||||
*/
|
*/
|
||||||
const noCodingActivityNode = ({ color, text }) => {
|
const noCodingActivityNode = ({ color, text }) => {
|
||||||
|
if (!isPrefixedHexColor(color)) {
|
||||||
|
throw new Error(`Invalid text color: "${color}"`);
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<text x="25" y="11" class="stat bold" fill="${color}">${text}</text>
|
<text x="25" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}</text>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +63,13 @@ const formatLanguageValue = ({ display_format, lang }) => {
|
|||||||
* @returns {string} The compact layout language SVG node.
|
* @returns {string} The compact layout language SVG node.
|
||||||
*/
|
*/
|
||||||
const createCompactLangNode = ({ lang, x, y, display_format }) => {
|
const createCompactLangNode = ({ lang, x, y, display_format }) => {
|
||||||
|
if (!Number.isFinite(x)) {
|
||||||
|
throw new Error(`Invalid x: "${x}"`);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(y)) {
|
||||||
|
throw new Error(`Invalid y: "${y}"`);
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const color = languageColors[lang.name] || "#858585";
|
const color = languageColors[lang.name] || "#858585";
|
||||||
const value = formatLanguageValue({ display_format, lang });
|
const value = formatLanguageValue({ display_format, lang });
|
||||||
@@ -68,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => {
|
|||||||
<g transform="translate(${x}, ${y})">
|
<g transform="translate(${x}, ${y})">
|
||||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||||
${lang.name} - ${value}
|
${encodeHTML(lang.name)} - ${encodeHTML(value)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
`;
|
`;
|
||||||
@@ -127,6 +137,13 @@ const createTextNode = ({
|
|||||||
progressBarBackgroundColor,
|
progressBarBackgroundColor,
|
||||||
progressBarWidth,
|
progressBarWidth,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!Number.isFinite(index)) {
|
||||||
|
throw new Error(`Invalid index: "${index}"`);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(progressBarWidth)) {
|
||||||
|
throw new Error(`Invalid progressBarWidth: "${progressBarWidth}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const staggerDelay = (index + 3) * 150;
|
const staggerDelay = (index + 3) * 150;
|
||||||
const cardProgress = hideProgress
|
const cardProgress = hideProgress
|
||||||
? null
|
? null
|
||||||
@@ -144,12 +161,12 @@ const createTextNode = ({
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
|
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
|
||||||
<text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
|
<text class="stat bold" y="12.5" data-testid="${encodeHTML(id)}">${encodeHTML(label)}:</text>
|
||||||
<text
|
<text
|
||||||
class="stat"
|
class="stat"
|
||||||
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
|
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
|
||||||
y="12.5"
|
y="12.5"
|
||||||
>${value}</text>
|
>${encodeHTML(value)}</text>
|
||||||
${cardProgress}
|
${cardProgress}
|
||||||
</g>
|
</g>
|
||||||
`;
|
`;
|
||||||
@@ -181,11 +198,15 @@ const recalculatePercentages = (languages) => {
|
|||||||
* @param {string} colors.textColor The text color.
|
* @param {string} colors.textColor The text color.
|
||||||
* @returns {string} Card CSS styles.
|
* @returns {string} Card CSS styles.
|
||||||
*/
|
*/
|
||||||
const getStyles = ({
|
const getStyles = function ({
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
titleColor,
|
titleColor,
|
||||||
textColor,
|
textColor,
|
||||||
}) => {
|
}) {
|
||||||
|
if (!isPrefixedHexColor(textColor)) {
|
||||||
|
throw new Error(`Invalid text color: "${textColor}"`);
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
.stat {
|
.stat {
|
||||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||||
@@ -379,7 +400,9 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
|
|||||||
progressBarColor: titleColor,
|
progressBarColor: titleColor,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
progressBarBackgroundColor:
|
progressBarBackgroundColor:
|
||||||
textColor === titleColor ? bgColor : textColor,
|
textColor === titleColor
|
||||||
|
? "#fff0" // transparent
|
||||||
|
: textColor,
|
||||||
hideProgress: hide_progress,
|
hideProgress: hide_progress,
|
||||||
progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
|
progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { encodeHTML } from "./html.js";
|
|
||||||
import { flexLayout } from "./render.js";
|
|
||||||
|
|
||||||
class Card {
|
|
||||||
/**
|
|
||||||
* Creates a new card instance.
|
|
||||||
*
|
|
||||||
* @param {object} args Card arguments.
|
|
||||||
* @param {number=} args.width Card width.
|
|
||||||
* @param {number=} args.height Card height.
|
|
||||||
* @param {number=} args.border_radius Card border radius.
|
|
||||||
* @param {string=} args.customTitle Card custom title.
|
|
||||||
* @param {string=} args.defaultTitle Card default title.
|
|
||||||
* @param {string=} args.titlePrefixIcon Card title prefix icon.
|
|
||||||
* @param {object} [args.colors={}] Card colors arguments.
|
|
||||||
* @param {string=} args.colors.titleColor Card title color.
|
|
||||||
* @param {string=} args.colors.textColor Card text color.
|
|
||||||
* @param {string=} args.colors.iconColor Card icon color.
|
|
||||||
* @param {string|string[]=} args.colors.bgColor Card background color.
|
|
||||||
* @param {string=} args.colors.borderColor Card border color.
|
|
||||||
*/
|
|
||||||
constructor({
|
|
||||||
width = 100,
|
|
||||||
height = 100,
|
|
||||||
border_radius = 4.5,
|
|
||||||
colors = {},
|
|
||||||
customTitle,
|
|
||||||
defaultTitle = "",
|
|
||||||
titlePrefixIcon,
|
|
||||||
}) {
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
|
|
||||||
this.hideBorder = false;
|
|
||||||
this.hideTitle = false;
|
|
||||||
|
|
||||||
this.border_radius = border_radius;
|
|
||||||
|
|
||||||
// returns theme based colors with proper overrides and defaults
|
|
||||||
this.colors = colors;
|
|
||||||
this.title =
|
|
||||||
customTitle === undefined
|
|
||||||
? encodeHTML(defaultTitle)
|
|
||||||
: encodeHTML(customTitle);
|
|
||||||
|
|
||||||
this.css = "";
|
|
||||||
|
|
||||||
this.paddingX = 25;
|
|
||||||
this.paddingY = 35;
|
|
||||||
this.titlePrefixIcon = titlePrefixIcon;
|
|
||||||
this.animations = true;
|
|
||||||
this.a11yTitle = "";
|
|
||||||
this.a11yDesc = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
disableAnimations() {
|
|
||||||
this.animations = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} props The props object.
|
|
||||||
* @param {string} props.title Accessibility title.
|
|
||||||
* @param {string} props.desc Accessibility description.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setAccessibilityLabel({ title, desc }) {
|
|
||||||
this.a11yTitle = title;
|
|
||||||
this.a11yDesc = desc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} value The CSS to add to the card.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setCSS(value) {
|
|
||||||
this.css = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {boolean} value Whether to hide the border or not.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setHideBorder(value) {
|
|
||||||
this.hideBorder = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {boolean} value Whether to hide the title or not.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setHideTitle(value) {
|
|
||||||
this.hideTitle = value;
|
|
||||||
if (value) {
|
|
||||||
this.height -= 30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} text The title to set.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setTitle(text) {
|
|
||||||
this.title = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string} The rendered card title.
|
|
||||||
*/
|
|
||||||
renderTitle() {
|
|
||||||
const titleText = `
|
|
||||||
<text
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
class="header"
|
|
||||||
data-testid="header"
|
|
||||||
>${this.title}</text>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const prefixIcon = `
|
|
||||||
<svg
|
|
||||||
class="icon"
|
|
||||||
x="0"
|
|
||||||
y="-13"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
version="1.1"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
>
|
|
||||||
${this.titlePrefixIcon}
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
return `
|
|
||||||
<g
|
|
||||||
data-testid="card-title"
|
|
||||||
transform="translate(${this.paddingX}, ${this.paddingY})"
|
|
||||||
>
|
|
||||||
${flexLayout({
|
|
||||||
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
|
|
||||||
gap: 25,
|
|
||||||
}).join("")}
|
|
||||||
</g>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string} The rendered card gradient.
|
|
||||||
*/
|
|
||||||
renderGradient() {
|
|
||||||
if (typeof this.colors.bgColor !== "object") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const gradients = this.colors.bgColor.slice(1);
|
|
||||||
return typeof this.colors.bgColor === "object"
|
|
||||||
? `
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="gradient"
|
|
||||||
gradientTransform="rotate(${this.colors.bgColor[0]})"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
${gradients.map((grad, index) => {
|
|
||||||
let offset = (index * 100) / (gradients.length - 1);
|
|
||||||
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
|
|
||||||
})}
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
`
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves css animations for a card.
|
|
||||||
*
|
|
||||||
* @returns {string} Animation css.
|
|
||||||
*/
|
|
||||||
getAnimations = () => {
|
|
||||||
return `
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body The inner body of the card.
|
|
||||||
* @returns {string} The rendered card.
|
|
||||||
*/
|
|
||||||
render(body) {
|
|
||||||
return `
|
|
||||||
<svg
|
|
||||||
width="${this.width}"
|
|
||||||
height="${this.height}"
|
|
||||||
viewBox="0 0 ${this.width} ${this.height}"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
role="img"
|
|
||||||
aria-labelledby="descId"
|
|
||||||
>
|
|
||||||
<title id="titleId">${this.a11yTitle}</title>
|
|
||||||
<desc id="descId">${this.a11yDesc}</desc>
|
|
||||||
<style>
|
|
||||||
.header {
|
|
||||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
|
||||||
fill: ${this.colors.titleColor};
|
|
||||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
@supports(-moz-appearance: auto) {
|
|
||||||
/* Selector detects Firefox */
|
|
||||||
.header { font-size: 15.5px; }
|
|
||||||
}
|
|
||||||
${this.css}
|
|
||||||
|
|
||||||
${this.getAnimations()}
|
|
||||||
${
|
|
||||||
this.animations === false
|
|
||||||
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
${this.renderGradient()}
|
|
||||||
|
|
||||||
<rect
|
|
||||||
data-testid="card-bg"
|
|
||||||
x="0.5"
|
|
||||||
y="0.5"
|
|
||||||
rx="${this.border_radius}"
|
|
||||||
height="99%"
|
|
||||||
stroke="${this.colors.borderColor}"
|
|
||||||
width="${this.width - 1}"
|
|
||||||
fill="${
|
|
||||||
typeof this.colors.bgColor === "object"
|
|
||||||
? "url(#gradient)"
|
|
||||||
: this.colors.bgColor
|
|
||||||
}"
|
|
||||||
stroke-opacity="${this.hideBorder ? 0 : 1}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
${this.hideTitle ? "" : this.renderTitle()}
|
|
||||||
|
|
||||||
<g
|
|
||||||
data-testid="main-card-body"
|
|
||||||
transform="translate(0, ${
|
|
||||||
this.hideTitle ? this.paddingX : this.paddingY + 20
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
${body}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Card };
|
|
||||||
export default Card;
|
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { isPrefixedHexColor, isValidGradient } from "./color.js";
|
||||||
|
import { encodeHTML } from "./html.js";
|
||||||
|
import { flexLayout } from "./render.js";
|
||||||
|
|
||||||
|
interface CardColors {
|
||||||
|
/** Card title color. */
|
||||||
|
titleColor?: string;
|
||||||
|
/** Card text color. */
|
||||||
|
textColor?: string;
|
||||||
|
/** Card icon color. */
|
||||||
|
iconColor?: string;
|
||||||
|
/** Card background color. */
|
||||||
|
bgColor?: string | Array<string>;
|
||||||
|
/** Card border color. */
|
||||||
|
borderColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Card {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
hideBorder: boolean;
|
||||||
|
hideTitle: boolean;
|
||||||
|
border_radius: number;
|
||||||
|
colors: CardColors;
|
||||||
|
title: string;
|
||||||
|
css: string;
|
||||||
|
paddingX: number;
|
||||||
|
paddingY: number;
|
||||||
|
titlePrefixIcon: string | undefined;
|
||||||
|
animations: boolean;
|
||||||
|
a11yTitle: string;
|
||||||
|
a11yDesc: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new card instance.
|
||||||
|
*
|
||||||
|
* The caller must ensure that the passed `titlePrefixIcon` is properly sanitized!
|
||||||
|
*
|
||||||
|
* @param props Card arguments.
|
||||||
|
* @param props.width Card width.
|
||||||
|
* @param props.height Card height.
|
||||||
|
* @param props.border_radius Card border radius.
|
||||||
|
* @param props.colors Card colors arguments.
|
||||||
|
* @param props.customTitle Card custom title.
|
||||||
|
* @param props.defaultTitle Card default title.
|
||||||
|
* @param props.titlePrefixIcon Sanitized card title prefix icon.
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
width = 100,
|
||||||
|
height = 100,
|
||||||
|
border_radius = 4.5,
|
||||||
|
colors = {},
|
||||||
|
customTitle,
|
||||||
|
defaultTitle = "",
|
||||||
|
titlePrefixIcon,
|
||||||
|
}: {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
border_radius?: number;
|
||||||
|
colors?: CardColors;
|
||||||
|
customTitle?: string;
|
||||||
|
defaultTitle?: string;
|
||||||
|
titlePrefixIcon?: string;
|
||||||
|
}) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
|
||||||
|
this.hideBorder = false;
|
||||||
|
this.hideTitle = false;
|
||||||
|
|
||||||
|
this.border_radius = parseFloat(String(border_radius));
|
||||||
|
|
||||||
|
// returns theme based colors with proper overrides and defaults
|
||||||
|
this.colors = colors;
|
||||||
|
this.title = customTitle === undefined ? defaultTitle : customTitle;
|
||||||
|
|
||||||
|
this.css = "";
|
||||||
|
|
||||||
|
this.paddingX = 25;
|
||||||
|
this.paddingY = 35;
|
||||||
|
this.titlePrefixIcon = titlePrefixIcon;
|
||||||
|
this.animations = true;
|
||||||
|
this.a11yTitle = "";
|
||||||
|
this.a11yDesc = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
disableAnimations(): void {
|
||||||
|
this.animations = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param props The props object.
|
||||||
|
* @param props.title Accessibility title.
|
||||||
|
* @param props.desc Accessibility description.
|
||||||
|
*/
|
||||||
|
setAccessibilityLabel({
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}): void {
|
||||||
|
this.a11yTitle = title;
|
||||||
|
this.a11yDesc = desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller must ensure that the passed `css` string is properly sanitized!
|
||||||
|
*
|
||||||
|
* @param value The sanitized CSS to add to the card.
|
||||||
|
*/
|
||||||
|
setCSS(value: string): void {
|
||||||
|
this.css = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param value Whether to hide the border or not.
|
||||||
|
*/
|
||||||
|
setHideBorder(value: boolean): void {
|
||||||
|
this.hideBorder = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param value Whether to hide the title or not.
|
||||||
|
*/
|
||||||
|
setHideTitle(value: boolean): void {
|
||||||
|
if (value && !this.hideTitle) {
|
||||||
|
this.height -= 30;
|
||||||
|
}
|
||||||
|
if (!value && this.hideTitle) {
|
||||||
|
this.height += 30;
|
||||||
|
}
|
||||||
|
this.hideTitle = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param text The title to set.
|
||||||
|
*/
|
||||||
|
setTitle(text: string): void {
|
||||||
|
this.title = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns The rendered card title.
|
||||||
|
*/
|
||||||
|
renderTitle(): string {
|
||||||
|
const titleText = `
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
class="header"
|
||||||
|
data-testid="header"
|
||||||
|
>${encodeHTML(this.title)}</text>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prefixIcon = `
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
x="0"
|
||||||
|
y="-13"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
version="1.1"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
${String(this.titlePrefixIcon)}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
return `
|
||||||
|
<g
|
||||||
|
data-testid="card-title"
|
||||||
|
transform="translate(${this.paddingX}, ${this.paddingY})"
|
||||||
|
>
|
||||||
|
${flexLayout({
|
||||||
|
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
|
||||||
|
gap: 25,
|
||||||
|
}).join("")}
|
||||||
|
</g>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns The rendered card gradient.
|
||||||
|
*/
|
||||||
|
renderGradient(): string {
|
||||||
|
if (typeof this.colors.bgColor !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!isValidGradient(this.colors.bgColor)) {
|
||||||
|
throw new Error(`Invalid gradient: ${this.colors.bgColor.join(",")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradients = this.colors.bgColor.slice(1);
|
||||||
|
return `
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="gradient"
|
||||||
|
gradientTransform="rotate(${String(this.colors.bgColor[0])})"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
${gradients
|
||||||
|
.map((grad, index) => {
|
||||||
|
const offset = (index * 100) / (gradients.length - 1);
|
||||||
|
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
|
||||||
|
})
|
||||||
|
.join(",")}
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves css animations for a card.
|
||||||
|
*
|
||||||
|
* @returns Animation css.
|
||||||
|
*/
|
||||||
|
getAnimations = (): string => {
|
||||||
|
return `
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller must ensure that the passed `body` string is properly sanitized!
|
||||||
|
*
|
||||||
|
* @param body The sanitized inner body of the card.
|
||||||
|
* @returns The rendered card.
|
||||||
|
*/
|
||||||
|
render(body: string): string {
|
||||||
|
if (!Number.isFinite(this.border_radius)) {
|
||||||
|
throw new Error(`Invalid border radius: "${this.border_radius}"`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.colors.titleColor !== undefined &&
|
||||||
|
!isPrefixedHexColor(this.colors.titleColor)
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid title color: "${this.colors.titleColor}"`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.colors.borderColor !== undefined &&
|
||||||
|
!isPrefixedHexColor(this.colors.borderColor)
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid border color: "${this.colors.borderColor}"`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.colors.bgColor !== undefined &&
|
||||||
|
!(typeof this.colors.bgColor === "object"
|
||||||
|
? isValidGradient(this.colors.bgColor)
|
||||||
|
: isPrefixedHexColor(this.colors.bgColor))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid background color: ${String(this.colors.bgColor)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg
|
||||||
|
width="${this.width}"
|
||||||
|
height="${this.height}"
|
||||||
|
viewBox="0 0 ${this.width} ${this.height}"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="descId"
|
||||||
|
>
|
||||||
|
<title id="titleId">${encodeHTML(this.a11yTitle)}</title>
|
||||||
|
<desc id="descId">${encodeHTML(this.a11yDesc)}</desc>
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||||
|
fill: ${String(this.colors.titleColor)};
|
||||||
|
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
@supports(-moz-appearance: auto) {
|
||||||
|
/* Selector detects Firefox */
|
||||||
|
.header { font-size: 15.5px; }
|
||||||
|
}
|
||||||
|
${this.css}
|
||||||
|
|
||||||
|
${this.getAnimations()}
|
||||||
|
${
|
||||||
|
this.animations
|
||||||
|
? ""
|
||||||
|
: `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
${this.renderGradient()}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
data-testid="card-bg"
|
||||||
|
x="0.5"
|
||||||
|
y="0.5"
|
||||||
|
rx="${this.border_radius}"
|
||||||
|
height="99%"
|
||||||
|
stroke="${String(this.colors.borderColor)}"
|
||||||
|
width="${this.width - 1}"
|
||||||
|
fill="${
|
||||||
|
typeof this.colors.bgColor === "object"
|
||||||
|
? "url(#gradient)"
|
||||||
|
: String(this.colors.bgColor)
|
||||||
|
}"
|
||||||
|
stroke-opacity="${this.hideBorder ? 0 : 1}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
${this.hideTitle ? "" : this.renderTitle()}
|
||||||
|
|
||||||
|
<g
|
||||||
|
data-testid="main-card-body"
|
||||||
|
transform="translate(0, ${
|
||||||
|
this.hideTitle ? this.paddingX : this.paddingY + 20
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
${body}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card };
|
||||||
|
export default Card;
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
const FALLBACK_LOCALE = "en";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* I18n translation class.
|
|
||||||
*/
|
|
||||||
class I18n {
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param {Object} options Options.
|
|
||||||
* @param {string=} options.locale Locale.
|
|
||||||
* @param {any} options.translations Translations.
|
|
||||||
*/
|
|
||||||
constructor({ locale, translations }) {
|
|
||||||
this.locale = locale || FALLBACK_LOCALE;
|
|
||||||
this.translations = translations;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get translation.
|
|
||||||
*
|
|
||||||
* @param {string} str String to translate.
|
|
||||||
* @returns {string} Translated string.
|
|
||||||
*/
|
|
||||||
t(str) {
|
|
||||||
if (!this.translations[str]) {
|
|
||||||
throw new Error(`${str} Translation string not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.translations[str][this.locale]) {
|
|
||||||
throw new Error(
|
|
||||||
`'${str}' translation not found for locale '${this.locale}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.translations[str][this.locale];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { I18n };
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const FALLBACK_LOCALE = "en";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I18n translation class.
|
||||||
|
*/
|
||||||
|
class I18n<Translations extends Record<string, Record<string, string>>> {
|
||||||
|
locale: string;
|
||||||
|
translations: Translations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param props Constructor arguments.
|
||||||
|
* @param props.locale Locale.
|
||||||
|
* @param props.translations Translations.
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
locale,
|
||||||
|
translations,
|
||||||
|
}: {
|
||||||
|
locale?: string;
|
||||||
|
translations: Translations;
|
||||||
|
}) {
|
||||||
|
this.locale = locale || FALLBACK_LOCALE;
|
||||||
|
this.translations = translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translation.
|
||||||
|
*
|
||||||
|
* @param str String to translate.
|
||||||
|
* @returns Translated string.
|
||||||
|
*/
|
||||||
|
t(str: keyof Translations & string): string {
|
||||||
|
const translation = this.translations[str];
|
||||||
|
if (!translation) {
|
||||||
|
throw new Error(`${str} Translation string not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localized = translation[this.locale];
|
||||||
|
if (!localized) {
|
||||||
|
throw new Error(
|
||||||
|
`'${str}' translation not found for locale '${this.locale}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return localized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { I18n };
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { themes } from "../themes/index.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a string is a valid hex color.
|
|
||||||
*
|
|
||||||
* @param {string} hexColor String to check.
|
|
||||||
* @returns {boolean} True if the given string is a valid hex color.
|
|
||||||
*/
|
|
||||||
const isValidHexColor = (hexColor) => {
|
|
||||||
return new RegExp(
|
|
||||||
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
|
|
||||||
).test(hexColor);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given string is a valid gradient.
|
|
||||||
*
|
|
||||||
* @param {string[]} colors Array of colors.
|
|
||||||
* @returns {boolean} True if the given string is a valid gradient.
|
|
||||||
*/
|
|
||||||
const isValidGradient = (colors) => {
|
|
||||||
return (
|
|
||||||
colors.length > 2 &&
|
|
||||||
colors.slice(1).every((color) => isValidHexColor(color))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a gradient if color has more than one valid hex codes else a single color.
|
|
||||||
*
|
|
||||||
* @param {string} color The color to parse.
|
|
||||||
* @param {string | string[]} fallbackColor The fallback color.
|
|
||||||
* @returns {string | string[]} The gradient or color.
|
|
||||||
*/
|
|
||||||
const fallbackColor = (color, fallbackColor) => {
|
|
||||||
let gradient = null;
|
|
||||||
|
|
||||||
let colors = color ? color.split(",") : [];
|
|
||||||
if (colors.length > 1 && isValidGradient(colors)) {
|
|
||||||
gradient = colors;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
|
|
||||||
fallbackColor
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object containing card colors.
|
|
||||||
* @typedef {{
|
|
||||||
* titleColor: string;
|
|
||||||
* iconColor: string;
|
|
||||||
* textColor: string;
|
|
||||||
* bgColor: string | string[];
|
|
||||||
* borderColor: string;
|
|
||||||
* ringColor: string;
|
|
||||||
* }} CardColors
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns theme based colors with proper overrides and defaults.
|
|
||||||
*
|
|
||||||
* @param {Object} args Function arguments.
|
|
||||||
* @param {string=} args.title_color Card title color.
|
|
||||||
* @param {string=} args.text_color Card text color.
|
|
||||||
* @param {string=} args.icon_color Card icon color.
|
|
||||||
* @param {string=} args.bg_color Card background color.
|
|
||||||
* @param {string=} args.border_color Card border color.
|
|
||||||
* @param {string=} args.ring_color Card ring color.
|
|
||||||
* @param {string=} args.theme Card theme.
|
|
||||||
* @returns {CardColors} Card colors.
|
|
||||||
*/
|
|
||||||
const getCardColors = ({
|
|
||||||
title_color,
|
|
||||||
text_color,
|
|
||||||
icon_color,
|
|
||||||
bg_color,
|
|
||||||
border_color,
|
|
||||||
ring_color,
|
|
||||||
theme,
|
|
||||||
}) => {
|
|
||||||
const defaultTheme = themes["default"];
|
|
||||||
const isThemeProvided =
|
|
||||||
theme !== null && theme !== undefined && theme in themes;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
|
|
||||||
|
|
||||||
const defaultBorderColor =
|
|
||||||
"border_color" in selectedTheme
|
|
||||||
? selectedTheme.border_color
|
|
||||||
: // @ts-ignore
|
|
||||||
defaultTheme.border_color;
|
|
||||||
|
|
||||||
// get the color provided by the user else the theme color
|
|
||||||
// finally if both colors are invalid fallback to default theme
|
|
||||||
const titleColor = fallbackColor(
|
|
||||||
title_color || selectedTheme.title_color,
|
|
||||||
"#" + defaultTheme.title_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
// get the color provided by the user else the theme color
|
|
||||||
// finally if both colors are invalid we use the titleColor
|
|
||||||
const ringColor = fallbackColor(
|
|
||||||
// @ts-ignore
|
|
||||||
ring_color || selectedTheme.ring_color,
|
|
||||||
titleColor,
|
|
||||||
);
|
|
||||||
const iconColor = fallbackColor(
|
|
||||||
icon_color || selectedTheme.icon_color,
|
|
||||||
"#" + defaultTheme.icon_color,
|
|
||||||
);
|
|
||||||
const textColor = fallbackColor(
|
|
||||||
text_color || selectedTheme.text_color,
|
|
||||||
"#" + defaultTheme.text_color,
|
|
||||||
);
|
|
||||||
const bgColor = fallbackColor(
|
|
||||||
bg_color || selectedTheme.bg_color,
|
|
||||||
"#" + defaultTheme.bg_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
const borderColor = fallbackColor(
|
|
||||||
border_color || defaultBorderColor,
|
|
||||||
"#" + defaultBorderColor,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof titleColor !== "string" ||
|
|
||||||
typeof textColor !== "string" ||
|
|
||||||
typeof ringColor !== "string" ||
|
|
||||||
typeof iconColor !== "string" ||
|
|
||||||
typeof borderColor !== "string"
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Unexpected behavior, all colors except background should be string.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
|
|
||||||
};
|
|
||||||
|
|
||||||
export { fallbackColor, getCardColors };
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { themes } from "../themes/index.js";
|
||||||
|
|
||||||
|
/** Matches a 3-, 4-, 6-, or 8-digit hex color with no leading `#`. */
|
||||||
|
const HEX_COLOR =
|
||||||
|
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a value is a bare hex color, i.e. hex digits with no `#` prefix
|
||||||
|
* (`"f00"`, `"ffffff"`). This is the form user-supplied color params and
|
||||||
|
* gradient stops arrive in.
|
||||||
|
*
|
||||||
|
* @param value Value to check.
|
||||||
|
* @returns True if the value is a bare hex color.
|
||||||
|
*/
|
||||||
|
const isBareHexColor = (value: unknown): boolean => {
|
||||||
|
return typeof value === "string" && HEX_COLOR.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a value is a `#`-prefixed hex color (`"#f00"`, `"#ffffff"`). This
|
||||||
|
* is the form colors take once resolved by {@link getCardColors}, i.e. right
|
||||||
|
* before they are written into the SVG.
|
||||||
|
*
|
||||||
|
* @param value Value to check.
|
||||||
|
* @returns True if the value is a `#`-prefixed hex color.
|
||||||
|
*/
|
||||||
|
const isPrefixedHexColor = (value: unknown): boolean => {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
value.startsWith("#") &&
|
||||||
|
HEX_COLOR.test(value.slice(1))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given parts form a valid gradient: a finite numeric angle
|
||||||
|
* followed by at least two bare-hex color stops, e.g. `["90", "f00", "0f0"]`.
|
||||||
|
* The angle is written into the SVG `gradientTransform="rotate(...)"`.
|
||||||
|
*
|
||||||
|
* @param parts Gradient parts: `[angle, ...stops]`.
|
||||||
|
* @returns True if the parts form a valid gradient.
|
||||||
|
*/
|
||||||
|
const isValidGradient = (parts: Array<string>): boolean => {
|
||||||
|
const [angle, ...stops] = parts;
|
||||||
|
return (
|
||||||
|
stops.length >= 2 &&
|
||||||
|
angle !== undefined &&
|
||||||
|
angle.trim() !== "" &&
|
||||||
|
Number.isFinite(Number(angle)) &&
|
||||||
|
stops.every(isBareHexColor)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid input for a color or gradient.
|
||||||
|
*
|
||||||
|
* @param color String to check, may be null or undefined.
|
||||||
|
* @returns True if the given string is a valid input.
|
||||||
|
*/
|
||||||
|
const isValidColorInput = (color: string | null | undefined): boolean => {
|
||||||
|
if (color === null || color === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isValidGradient(color.split(",")) || isBareHexColor(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates over a collection of colors inputs and verifies that each is a valid color or gradient.
|
||||||
|
*
|
||||||
|
* @param colors Object whose values are checked as valid color inputs.
|
||||||
|
* @return The first key where the associated input value is not valid. null if all inputs are valid.
|
||||||
|
*/
|
||||||
|
const findInvalidColor = (
|
||||||
|
colors: Record<string, string | null | undefined>,
|
||||||
|
): string | null => {
|
||||||
|
for (const [key, value] of Object.entries(colors)) {
|
||||||
|
if (!isValidColorInput(value)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a gradient if color has more than one valid hex codes else a single color.
|
||||||
|
*
|
||||||
|
* @param color The color to parse.
|
||||||
|
* @param fallbackColor The fallback color.
|
||||||
|
* @returns The gradient or color.
|
||||||
|
*/
|
||||||
|
const fallbackColor = (
|
||||||
|
color: string | undefined,
|
||||||
|
fallbackColor: string | Array<string>,
|
||||||
|
): string | Array<string> => {
|
||||||
|
const colors = color ? color.split(",") : [];
|
||||||
|
if (colors.length > 1 && isValidGradient(colors)) {
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color !== undefined && isBareHexColor(color)) {
|
||||||
|
return `#${color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object containing card colors.
|
||||||
|
*/
|
||||||
|
interface CardColors {
|
||||||
|
titleColor: string;
|
||||||
|
iconColor: string;
|
||||||
|
textColor: string;
|
||||||
|
bgColor: string | Array<string>;
|
||||||
|
borderColor: string;
|
||||||
|
ringColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns theme based colors with proper overrides and defaults.
|
||||||
|
*
|
||||||
|
* @param props Function arguments.
|
||||||
|
* @param props.title_color Card title color.
|
||||||
|
* @param props.text_color Card text color.
|
||||||
|
* @param props.icon_color Card icon color.
|
||||||
|
* @param props.bg_color Card background color.
|
||||||
|
* @param props.border_color Card border color.
|
||||||
|
* @param props.ring_color Card ring color.
|
||||||
|
* @param props.theme Card theme.
|
||||||
|
* @returns Card colors.
|
||||||
|
*/
|
||||||
|
const getCardColors = ({
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
icon_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
ring_color,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
title_color?: string | undefined;
|
||||||
|
text_color?: string | undefined;
|
||||||
|
icon_color?: string | undefined;
|
||||||
|
bg_color?: string | undefined;
|
||||||
|
border_color?: string | undefined;
|
||||||
|
ring_color?: string | undefined;
|
||||||
|
theme?: string | undefined;
|
||||||
|
}): CardColors => {
|
||||||
|
const defaultTheme = themes.default;
|
||||||
|
const isThemeProvided = theme !== undefined && theme in themes;
|
||||||
|
|
||||||
|
const selectedTheme = isThemeProvided
|
||||||
|
? themes[theme as keyof typeof themes]
|
||||||
|
: defaultTheme;
|
||||||
|
|
||||||
|
const defaultBorderColor =
|
||||||
|
"border_color" in selectedTheme
|
||||||
|
? selectedTheme.border_color
|
||||||
|
: defaultTheme.border_color;
|
||||||
|
|
||||||
|
// get the color provided by the user else the theme color
|
||||||
|
// finally if both colors are invalid fallback to default theme
|
||||||
|
const titleColor = fallbackColor(
|
||||||
|
title_color || selectedTheme.title_color,
|
||||||
|
"#" + defaultTheme.title_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
// get the color provided by the user else the theme color
|
||||||
|
// finally if both colors are invalid we use the titleColor
|
||||||
|
// NOTE: no built-in theme defines `ring_color`, so it falls back to the title color.
|
||||||
|
const ringColor = fallbackColor(ring_color, titleColor);
|
||||||
|
const iconColor = fallbackColor(
|
||||||
|
icon_color || selectedTheme.icon_color,
|
||||||
|
"#" + defaultTheme.icon_color,
|
||||||
|
);
|
||||||
|
const textColor = fallbackColor(
|
||||||
|
text_color || selectedTheme.text_color,
|
||||||
|
"#" + defaultTheme.text_color,
|
||||||
|
);
|
||||||
|
const bgColor = fallbackColor(
|
||||||
|
bg_color || selectedTheme.bg_color,
|
||||||
|
"#" + defaultTheme.bg_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
const borderColor = fallbackColor(
|
||||||
|
border_color || defaultBorderColor,
|
||||||
|
"#" + defaultBorderColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof titleColor !== "string" ||
|
||||||
|
typeof textColor !== "string" ||
|
||||||
|
typeof ringColor !== "string" ||
|
||||||
|
typeof iconColor !== "string" ||
|
||||||
|
typeof borderColor !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Unexpected behavior, all colors except background should be string.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
fallbackColor,
|
||||||
|
getCardColors,
|
||||||
|
findInvalidColor,
|
||||||
|
isValidGradient,
|
||||||
|
isBareHexColor,
|
||||||
|
isPrefixedHexColor,
|
||||||
|
};
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | undefined} value Comma-separated string.
|
|
||||||
* @returns {string[] | undefined} Parsed string values.
|
|
||||||
*/
|
|
||||||
const parseCsv = (value) => {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return value.split(",");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Record<string, string | undefined>} env Environment variables to inspect.
|
|
||||||
* @returns {{name: string, value: string}[]} Personal access tokens found in the environment.
|
|
||||||
*/
|
|
||||||
const parsePATsFromEnv = (env) => {
|
|
||||||
return Object.keys(env)
|
|
||||||
.filter((key) => /PAT_\d*$/.exec(key))
|
|
||||||
.map((name) => ({
|
|
||||||
name,
|
|
||||||
value: env[name],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Record<string, string | undefined>} `process.env` if available, otherwise `{}`.
|
|
||||||
*/
|
|
||||||
const getDefaultEnv = () => {
|
|
||||||
if (typeof process !== "undefined" && process?.env) {
|
|
||||||
return process.env;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Partial<ReturnType<typeof loadConfigFromEnv>>} config (Partial) config values to normalize.
|
|
||||||
* @returns {{
|
|
||||||
* whitelist: string[] | undefined,
|
|
||||||
* gistWhitelist: string[] | undefined,
|
|
||||||
* excludeRepositories: string[],
|
|
||||||
* fetchMultiPageStars: string | undefined,
|
|
||||||
* pats: {name: string, value: string}[],
|
|
||||||
* }} Normalized config object with defaults applied.
|
|
||||||
*/
|
|
||||||
const normalizeConfig = (config = {}) => {
|
|
||||||
return {
|
|
||||||
whitelist: config.whitelist,
|
|
||||||
gistWhitelist: config.gistWhitelist,
|
|
||||||
excludeRepositories: config.excludeRepositories || [],
|
|
||||||
fetchMultiPageStars: config.fetchMultiPageStars,
|
|
||||||
pats: config.pats || [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentConfig;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Record<string, string | undefined>} env Environment variables used to build the runtime config.
|
|
||||||
*/
|
|
||||||
export const loadConfigFromEnv = (env = getDefaultEnv()) => {
|
|
||||||
const whitelist = parseCsv(env.WHITELIST);
|
|
||||||
const gistWhitelist = parseCsv(env.GIST_WHITELIST);
|
|
||||||
const excludeRepositories = parseCsv(env.EXCLUDE_REPO) || [];
|
|
||||||
|
|
||||||
currentConfig = normalizeConfig({
|
|
||||||
whitelist,
|
|
||||||
gistWhitelist,
|
|
||||||
excludeRepositories,
|
|
||||||
fetchMultiPageStars: env.FETCH_MULTI_PAGE_STARS,
|
|
||||||
pats: parsePATsFromEnv(env),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
loadConfigFromEnv();
|
|
||||||
|
|
||||||
export const getConfig = () => currentConfig;
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
type Env = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
interface PersonalAccessToken {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
whitelist: Array<string> | undefined;
|
||||||
|
gistWhitelist: Array<string> | undefined;
|
||||||
|
excludeRepositories: Array<string>;
|
||||||
|
fetchMultiPageStars: string | undefined;
|
||||||
|
pats: Array<PersonalAccessToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param value Comma-separated string.
|
||||||
|
* @returns Parsed string values.
|
||||||
|
*/
|
||||||
|
const parseCsv = (value: string | undefined): Array<string> | undefined => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value.split(",");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param env Environment variables to inspect.
|
||||||
|
* @returns Personal access tokens found in the environment.
|
||||||
|
*/
|
||||||
|
const parsePATsFromEnv = (env: Env): Array<PersonalAccessToken> => {
|
||||||
|
return Object.keys(env)
|
||||||
|
.filter((key) => /PAT_\d*$/.exec(key))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
value: env[name] ?? "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns `process.env` if available, otherwise `{}`.
|
||||||
|
*/
|
||||||
|
const getDefaultEnv = (): Env => {
|
||||||
|
const processEnv = (globalThis as { process?: { env?: Env } }).process?.env;
|
||||||
|
return processEnv ?? {};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param config (Partial) config values to normalize.
|
||||||
|
* @returns Normalized config object with defaults applied.
|
||||||
|
*/
|
||||||
|
const normalizeConfig = (config: Partial<Config> = {}): Config => {
|
||||||
|
return {
|
||||||
|
whitelist: config.whitelist,
|
||||||
|
gistWhitelist: config.gistWhitelist,
|
||||||
|
excludeRepositories: config.excludeRepositories ?? [],
|
||||||
|
fetchMultiPageStars: config.fetchMultiPageStars,
|
||||||
|
pats: config.pats ?? [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentConfig: Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param env Environment variables used to build the runtime config.
|
||||||
|
*/
|
||||||
|
export const loadConfigFromEnv = (env: Env = getDefaultEnv()): void => {
|
||||||
|
const whitelist = parseCsv(env["WHITELIST"]);
|
||||||
|
const gistWhitelist = parseCsv(env["GIST_WHITELIST"]);
|
||||||
|
const excludeRepositories = parseCsv(env["EXCLUDE_REPO"]) ?? [];
|
||||||
|
|
||||||
|
currentConfig = normalizeConfig({
|
||||||
|
whitelist,
|
||||||
|
gistWhitelist,
|
||||||
|
excludeRepositories,
|
||||||
|
fetchMultiPageStars: env["FETCH_MULTI_PAGE_STARS"],
|
||||||
|
pats: parsePATsFromEnv(env),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
loadConfigFromEnv();
|
||||||
|
|
||||||
|
export const getConfig = (): Config => currentConfig;
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid owner affiliations for GitHub API queries.
|
|
||||||
* @type {Array<string>}
|
|
||||||
*/
|
|
||||||
const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];
|
|
||||||
|
|
||||||
export { OWNER_AFFILIATIONS };
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Valid owner affiliations for GitHub API queries.
|
||||||
|
*/
|
||||||
|
const OWNER_AFFILIATIONS: Array<string> = [
|
||||||
|
"OWNER",
|
||||||
|
"COLLABORATOR",
|
||||||
|
"ORGANIZATION_MEMBER",
|
||||||
|
];
|
||||||
|
|
||||||
|
export { OWNER_AFFILIATIONS };
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { OWNER_AFFILIATIONS } from "./constants.js";
|
import { OWNER_AFFILIATIONS } from "./constants.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {string} A general message to ask user to try again later.
|
* A general message to ask user to try again later.
|
||||||
*/
|
*/
|
||||||
const TRY_AGAIN_LATER = "Please try again later";
|
const TRY_AGAIN_LATER = "Please try again later";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {Object<string, string>} A map of error types to secondary error messages.
|
* A map of error types to secondary error messages.
|
||||||
*/
|
*/
|
||||||
const SECONDARY_ERROR_MESSAGES = {
|
const SECONDARY_ERROR_MESSAGES = {
|
||||||
MAX_RETRY:
|
MAX_RETRY:
|
||||||
@@ -28,16 +26,21 @@ const SECONDARY_ERROR_MESSAGES = {
|
|||||||
* Custom error class to handle custom GRS errors.
|
* Custom error class to handle custom GRS errors.
|
||||||
*/
|
*/
|
||||||
class CustomError extends Error {
|
class CustomError extends Error {
|
||||||
|
type: string;
|
||||||
|
secondaryMessage: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom error constructor.
|
* Custom error constructor.
|
||||||
*
|
*
|
||||||
* @param {string} message Error message.
|
* @param message Error message.
|
||||||
* @param {string} type Error type.
|
* @param type Error type.
|
||||||
*/
|
*/
|
||||||
constructor(message, type) {
|
constructor(message: string, type: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
|
type PartialRecord = Partial<Record<string, string>>;
|
||||||
|
this.secondaryMessage =
|
||||||
|
(SECONDARY_ERROR_MESSAGES as PartialRecord)[type] ?? type;
|
||||||
}
|
}
|
||||||
|
|
||||||
static MAX_RETRY = "MAX_RETRY";
|
static MAX_RETRY = "MAX_RETRY";
|
||||||
@@ -53,13 +56,16 @@ class CustomError extends Error {
|
|||||||
* Missing query parameter class.
|
* Missing query parameter class.
|
||||||
*/
|
*/
|
||||||
class MissingParamError extends Error {
|
class MissingParamError extends Error {
|
||||||
|
missedParams: Array<string>;
|
||||||
|
secondaryMessage: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Missing query parameter error constructor.
|
* Missing query parameter error constructor.
|
||||||
*
|
*
|
||||||
* @param {string[]} missedParams An array of missing parameters names.
|
* @param missedParams An array of missing parameters names.
|
||||||
* @param {string=} secondaryMessage Optional secondary message to display.
|
* @param secondaryMessage Optional secondary message to display.
|
||||||
*/
|
*/
|
||||||
constructor(missedParams, secondaryMessage) {
|
constructor(missedParams: Array<string>, secondaryMessage?: string) {
|
||||||
const msg = `Missing params ${missedParams
|
const msg = `Missing params ${missedParams
|
||||||
.map((p) => `"${p}"`)
|
.map((p) => `"${p}"`)
|
||||||
.join(", ")} make sure you pass the parameters in URL`;
|
.join(", ")} make sure you pass the parameters in URL`;
|
||||||
@@ -72,10 +78,10 @@ class MissingParamError extends Error {
|
|||||||
/**
|
/**
|
||||||
* Retrieve secondary message from an error object.
|
* Retrieve secondary message from an error object.
|
||||||
*
|
*
|
||||||
* @param {Error} err The error object.
|
* @param err The error object.
|
||||||
* @returns {string|undefined} The secondary message if available, otherwise undefined.
|
* @returns The secondary message if available, otherwise undefined.
|
||||||
*/
|
*/
|
||||||
const retrieveSecondaryMessage = (err) => {
|
const retrieveSecondaryMessage = (err: Error): string | undefined => {
|
||||||
return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
|
return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
|
||||||
? err.secondaryMessage
|
? err.secondaryMessage
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import { encodeHTML } from "./html.js";
|
import { encodeHTML } from "./html.js";
|
||||||
import { splitWrappedText } from "./render.js";
|
import { splitWrappedText } from "./render.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
||||||
*
|
*
|
||||||
* @param {number} num The number to format.
|
* @param num The number to format.
|
||||||
* @param {number=} precision The number of decimal places to include.
|
* @param precision The number of decimal places to include.
|
||||||
* @returns {string|number} The formatted number.
|
* @returns The formatted number.
|
||||||
*/
|
*/
|
||||||
const kFormatter = (num, precision) => {
|
const kFormatter = (num: number, precision?: number): string | number => {
|
||||||
const abs = Math.abs(num);
|
const abs = Math.abs(num);
|
||||||
const sign = Math.sign(num);
|
const sign = Math.sign(num);
|
||||||
|
|
||||||
@@ -22,17 +20,17 @@ const kFormatter = (num, precision) => {
|
|||||||
return sign * abs;
|
return sign * abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
|
return `${sign * parseFloat((abs / 1000).toFixed(1))}k`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert bytes to a human-readable string representation.
|
* Convert bytes to a human-readable string representation.
|
||||||
*
|
*
|
||||||
* @param {number} bytes The number of bytes to convert.
|
* @param bytes The number of bytes to convert.
|
||||||
* @returns {string} The human-readable representation of bytes.
|
* @returns The human-readable representation of bytes.
|
||||||
* @throws {Error} If bytes is negative or too large.
|
* @throws {Error} If bytes is negative or too large.
|
||||||
*/
|
*/
|
||||||
const formatBytes = (bytes) => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes < 0) {
|
if (bytes < 0) {
|
||||||
throw new Error("Bytes must be a non-negative number");
|
throw new Error("Bytes must be a non-negative number");
|
||||||
}
|
}
|
||||||
@@ -45,23 +43,29 @@ const formatBytes = (bytes) => {
|
|||||||
const base = 1024;
|
const base = 1024;
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(base));
|
const i = Math.floor(Math.log(bytes) / Math.log(base));
|
||||||
|
|
||||||
if (i >= sizes.length) {
|
const unit = sizes[i];
|
||||||
|
if (unit === undefined) {
|
||||||
throw new Error("Bytes is too large to convert to a human-readable string");
|
throw new Error("Bytes is too large to convert to a human-readable string");
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${unit}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split text over multiple lines based on the card width.
|
* Split text over multiple lines based on the card width.
|
||||||
*
|
*
|
||||||
* @param {string} text Text to split.
|
* @param text Text to split.
|
||||||
* @param {number} width Available wrap width in px.
|
* @param width Available wrap width in px.
|
||||||
* @param {number} fontSize Font size in px.
|
* @param fontSize Font size in px.
|
||||||
* @param {number} maxLines Maximum number of lines.
|
* @param maxLines Maximum number of lines.
|
||||||
* @returns {string[]} Array of lines.
|
* @returns Array of lines.
|
||||||
*/
|
*/
|
||||||
const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
|
const wrapTextMultiline = (
|
||||||
|
text: string,
|
||||||
|
width: number,
|
||||||
|
fontSize: number,
|
||||||
|
maxLines = 3,
|
||||||
|
): Array<string> => {
|
||||||
const wrapped = splitWrappedText(text, fontSize, width);
|
const wrapped = splitWrappedText(text, fontSize, width);
|
||||||
const lines = wrapped
|
const lines = wrapped
|
||||||
.map((line) => encodeHTML(line.trim()))
|
.map((line) => encodeHTML(line.trim()))
|
||||||
@@ -69,7 +73,11 @@ const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
|
|||||||
|
|
||||||
// Add "..." to the last line if the text exceeds maxLines
|
// Add "..." to the last line if the text exceeds maxLines
|
||||||
if (wrapped.length > maxLines) {
|
if (wrapped.length > maxLines) {
|
||||||
lines[maxLines - 1] += "...";
|
const lastIndex = maxLines - 1;
|
||||||
|
const lastLine = lines[lastIndex];
|
||||||
|
if (lastLine !== undefined) {
|
||||||
|
lines[lastIndex] = `${lastLine}...`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove empty lines if text fits in less than maxLines lines
|
// Remove empty lines if text fits in less than maxLines lines
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode string as HTML.
|
* Encode string as HTML.
|
||||||
*
|
*
|
||||||
* @see https://stackoverflow.com/a/48073476/10629172
|
* @see https://stackoverflow.com/a/48073476/10629172
|
||||||
*
|
|
||||||
* @param {string} str String to encode.
|
|
||||||
* @returns {string} Encoded string.
|
|
||||||
*/
|
*/
|
||||||
const encodeHTML = (str) => {
|
const encodeHTML = (str: string): string => {
|
||||||
return (
|
return (
|
||||||
str
|
str
|
||||||
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
|
.replace(/[\u00A0-\u9999<>&"'](?!#)/gim, (i: string) => {
|
||||||
return "&#" + i.charCodeAt(0) + ";";
|
return `&#${i.charCodeAt(0)};`;
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\u0008/gim, "")
|
.replace(/\u0008/gim, "")
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send GraphQL request to GitHub API.
|
|
||||||
*
|
|
||||||
* @param {import('axios').AxiosRequestConfig['data']} data Request data.
|
|
||||||
* @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
|
|
||||||
* @returns {Promise<any>} Request response.
|
|
||||||
*/
|
|
||||||
const request = (data, headers) => {
|
|
||||||
return axios({
|
|
||||||
url: "https://api.github.com/graphql",
|
|
||||||
method: "post",
|
|
||||||
headers,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export { request };
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user