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:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js (via input)
|
||||
if: ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
|
||||
- name: Setup Node.js (via .nvmrc)
|
||||
if: ${{ !inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -67,8 +67,10 @@ jobs:
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
# Only install chromium since it is the only browser enabled in
|
||||
# apps/frontend/playwright.config.ts. Update this if more projects are added.
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm --filter ./apps/frontend/ run test:e2e
|
||||
@@ -85,7 +87,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
@@ -106,7 +108,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
@@ -10,9 +10,11 @@ on:
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "45 * * * *"
|
||||
- cron: "15,45 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
triggerRepeatRecent:
|
||||
if: |
|
||||
@@ -24,7 +26,7 @@ jobs:
|
||||
steps:
|
||||
- name: Make Request
|
||||
id: myRequest
|
||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
||||
uses: fjogeleit/http-request-action@f98bb26551cd1af7d3eb2674578a80754ece88db # v2
|
||||
with:
|
||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||
method: "POST"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
@@ -14,5 +14,5 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"prettier.prettier-vscode",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
|
||||
Vendored
+1
-2
@@ -3,8 +3,7 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": ["Wakatime"]
|
||||
"cSpell.words": ["retryer", "Wakatime"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "catalog:default",
|
||||
"@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) {
|
||||
console.log("No recent requests found.");
|
||||
} else {
|
||||
await makeRequests(urls, 5);
|
||||
await makeRequests(urls, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const STATS_MOCK_RESPONSE = {
|
||||
followers: { totalCount: 0 },
|
||||
repositories: {
|
||||
totalCount: 1,
|
||||
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
|
||||
nodes: [{ name: REPO, stargazerCount: 1 }],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
@@ -110,9 +110,7 @@ const REPO_MOCK_RESPONSE = {
|
||||
isPrivate: false,
|
||||
isArchived: false,
|
||||
isTemplate: false,
|
||||
stargazers: {
|
||||
totalCount: 1,
|
||||
},
|
||||
stargazerCount: 1,
|
||||
description: "Simple cra test repo.",
|
||||
primaryLanguage: {
|
||||
color: "#f1e05a",
|
||||
|
||||
@@ -443,7 +443,7 @@ exports[`Test /api contract > should match the public many-params response snaps
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"graphqlRequest": "{"query":"\\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": [
|
||||
[
|
||||
"Cache-Control",
|
||||
|
||||
@@ -341,7 +341,7 @@ exports[`Test /api/pin contract > should match the public many-params response s
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
|
||||
"graphqlRequest": "{"query":"\\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": [
|
||||
[
|
||||
"Cache-Control",
|
||||
|
||||
@@ -26,9 +26,9 @@ export const data_stats = {
|
||||
repositories: {
|
||||
totalCount: 3,
|
||||
nodes: [
|
||||
{ name: "repo-keep-1", stargazers: { totalCount: 1500 } },
|
||||
{ name: "repo-exclude-me", stargazers: { totalCount: 9999 } },
|
||||
{ name: "repo-keep-2", stargazers: { totalCount: 2600 } },
|
||||
{ name: "repo-keep-1", stargazerCount: 1500 },
|
||||
{ name: "repo-exclude-me", stargazerCount: 9999 },
|
||||
{ name: "repo-keep-2", stargazerCount: 2600 },
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
@@ -73,9 +73,7 @@ export const data_user = {
|
||||
username: "anuraghazra",
|
||||
name: "convoychat",
|
||||
nameWithOwner: "anuraghazra/convoychat",
|
||||
stargazers: {
|
||||
totalCount: 38000,
|
||||
},
|
||||
stargazerCount: 38000,
|
||||
description:
|
||||
"Help us take over the world with a deeply customizable React, TypeScript and GraphQL chat app that has enough text to wrap across multiple lines in the repository card.",
|
||||
primaryLanguage: {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Puts the SPA into an authenticated state without contacting GitHub by
|
||||
* stubbing the OAuth code exchange and the follow-up user-access lookup:
|
||||
* `authenticate` returns a userId (which flips `isAuthenticated` to true), and
|
||||
* `user-access` returns metadata so AppTrends does not immediately log back out.
|
||||
* @param page - The Playwright page to install the route handlers on.
|
||||
*/
|
||||
async function mockAuthEndpoints(page: Page): Promise<void> {
|
||||
await page.route("**/api/authenticate**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ userId: "octocat", needDowngrade: false }),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/user-access**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ token: "test-token", privateAccess: "false" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("AppTrends auth-driven stage transition", () => {
|
||||
test("auto-advances from Login to Select a Card once authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthEndpoints(page);
|
||||
|
||||
// Land on the app as GitHub does after the OAuth redirect (URL carries `code`).
|
||||
await page.goto("?code=test-oauth-code");
|
||||
|
||||
// AppTrends starts unauthenticated on stage 0 ("Login"). When the code
|
||||
// exchange flips `isAuthenticated` false -> true, the render-phase
|
||||
// transition must advance the stepper to stage 1 ("Select a Card").
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Select a Card",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a manually selected earlier step instead of re-forcing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthEndpoints(page);
|
||||
await page.goto("?code=test-oauth-code");
|
||||
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await expect(heading).toContainText("Select a Card");
|
||||
|
||||
// Going back to "Login" while still authenticated must stick: the transition
|
||||
// fires only on an auth *change*, so a later render must not snap the user
|
||||
// forward again. A bug that dropped the previous-value guard would bounce
|
||||
// the heading straight back to "Select a Card" here.
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(heading).toContainText("Login");
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => {
|
||||
// Logo exists
|
||||
await expect(page.locator('img[alt="logo"]')).toBeVisible();
|
||||
|
||||
// Star on GitHub button
|
||||
const starButton = page.getByRole("button", { name: /star on/i });
|
||||
// Star on GitHub link
|
||||
const starButton = page.getByRole("link", { name: /star on/i });
|
||||
await expect(starButton).toBeVisible();
|
||||
|
||||
const githubLink = page.locator(
|
||||
@@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => {
|
||||
await expect(guestBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test("theme picker switches and persists the theme", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
const html = page.locator("html");
|
||||
const trigger = page.getByRole("button", { name: /choose theme/i });
|
||||
const options = page.getByRole("menuitemradio");
|
||||
|
||||
// The menu is closed initially.
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(options).toHaveCount(0);
|
||||
|
||||
// Opening it reveals the options (portaled to body, above the stepper).
|
||||
await trigger.click();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Read the themes off the options instead of hardcoding their names.
|
||||
const firstTheme = await options.first().getAttribute("data-theme-value");
|
||||
const lastTheme = await options.last().getAttribute("data-theme-value");
|
||||
|
||||
// Selecting an option applies the theme, persists it, and closes the menu.
|
||||
await options.last().click();
|
||||
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
|
||||
await expect(options).toHaveCount(0);
|
||||
await expect(
|
||||
page.evaluate(() => localStorage.getItem("theme")),
|
||||
).resolves.toBe(lastTheme);
|
||||
|
||||
// The choice survives a reload and is marked as selected on reopen.
|
||||
await page.reload();
|
||||
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
|
||||
await trigger.click();
|
||||
await expect(options.last()).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Switching to another option works too.
|
||||
await options.first().click();
|
||||
await expect(html).toHaveAttribute("data-theme", firstTheme ?? "");
|
||||
});
|
||||
|
||||
test("navigates between steps", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("selecting 'None' progress style hides the rank circle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("");
|
||||
|
||||
// Stage 0 -> 2: go to customization
|
||||
await page.getByRole("button", { name: "Modify Parameters" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Modify Card Parameters",
|
||||
);
|
||||
|
||||
const preview = page.locator("#svgWrapper");
|
||||
const rankCircle = preview.locator('[data-testid="rank-circle"]');
|
||||
|
||||
// Default "Rank" progress style renders the rank circle.
|
||||
await expect(preview).toBeAttached();
|
||||
await expect(rankCircle).toHaveCount(1);
|
||||
|
||||
await page.getByRole("combobox").selectOption({ label: "None" });
|
||||
|
||||
await expect(rankCircle).toHaveCount(0);
|
||||
});
|
||||
@@ -37,6 +37,24 @@
|
||||
<title>GitHub Stats Extended</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply the persisted theme from localStorage before first paint. This is
|
||||
// what makes a saved theme survive a reload when it differs from the OS
|
||||
// preference (DaisyUI's `--prefersdark` only covers the OS default, and
|
||||
// nothing in React sets `data-theme` on mount).
|
||||
// Also suppress color transitions until after the first paint so elements
|
||||
// don't animate from their unstyled state on load.
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("theme");
|
||||
const matchesDarkTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
var theme = stored || (matchesDarkTheme ? "dark" : "light");
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
|
||||
@@ -14,29 +14,29 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-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-dom": "^19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"redux": "^5.0.1",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"uuid": "^14.0.0"
|
||||
"uuid": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"vite": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
|
||||
@@ -1,39 +1,76 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
import type { CSSProperties, JSX } from "react";
|
||||
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
import { CardImage } from "./CardImage";
|
||||
import { LIGHT_CARD_BG } from "./themeBackdrop";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
card: CardUrlBuilder;
|
||||
stage: number;
|
||||
selected?: boolean;
|
||||
compact?: boolean;
|
||||
fixedSize?: boolean;
|
||||
/** CSS background for the card surface (e.g. a theme's own background). */
|
||||
backgroundColor?: string;
|
||||
/** Title color, used when the card sits on a custom background. */
|
||||
titleColor?: string;
|
||||
}
|
||||
|
||||
export const Card = ({
|
||||
title,
|
||||
description,
|
||||
imageSrc,
|
||||
card,
|
||||
stage,
|
||||
selected = false,
|
||||
compact = false,
|
||||
fixedSize = false,
|
||||
backgroundColor,
|
||||
titleColor,
|
||||
}: CardProps): JSX.Element => {
|
||||
const customBackground = backgroundColor
|
||||
? selected
|
||||
? `color-mix(in oklab, var(--color-primary) ${backgroundColor === LIGHT_CARD_BG ? "10%" : "25%"}, ${backgroundColor})`
|
||||
: backgroundColor
|
||||
: undefined;
|
||||
|
||||
const cardStyle = customBackground
|
||||
? ({ "--card-bg": customBackground } as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("p-6 rounded border-2", {
|
||||
className={clsx("p-6 rounded border-4", {
|
||||
"h-[370px] w-[510px]": fixedSize,
|
||||
"border-blue-500 bg-blue-50": selected,
|
||||
"border-gray-200 bg-white hover:bg-gray-50": !selected,
|
||||
"border-primary": selected,
|
||||
"border-base-300": !selected,
|
||||
"bg-[var(--card-bg)]": !!customBackground,
|
||||
// Token backgrounds only apply when no custom background is given.
|
||||
"bg-primary/10": selected && !customBackground,
|
||||
"bg-base-100 hover:bg-base-200": !selected && !customBackground,
|
||||
"hover:bg-base-300": !selected && !!customBackground,
|
||||
})}
|
||||
style={cardStyle}
|
||||
data-theme={
|
||||
stage == 3
|
||||
? backgroundColor === LIGHT_CARD_BG || !backgroundColor
|
||||
? "light"
|
||||
: "dark"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
|
||||
<h2
|
||||
className="text-xl font-medium title-font text-base-content"
|
||||
style={titleColor ? { color: titleColor } : undefined}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
|
||||
<CardImage
|
||||
imageSrc={imageSrc}
|
||||
card={card}
|
||||
compact={compact}
|
||||
className={clsx({ "flex justify-center": fixedSize })}
|
||||
stage={stage}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import { HOST } from "../../constants";
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
import { SvgInline } from "./SvgInline";
|
||||
|
||||
interface CardImageProps {
|
||||
imageSrc: string;
|
||||
card: CardUrlBuilder;
|
||||
stage: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardImage = ({
|
||||
imageSrc,
|
||||
card,
|
||||
stage,
|
||||
compact = false,
|
||||
className,
|
||||
}: CardImageProps) => {
|
||||
const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`;
|
||||
// `client=wizard` marks requests coming from the wizard preview.
|
||||
const fullImageSrc = card.client("wizard").toApiUrl(HOST);
|
||||
|
||||
return (
|
||||
<div className={clsx("w-full relative", className)}>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
|
||||
FETCH_MULTI_PAGE_STARS: "10",
|
||||
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
|
||||
loadConfigFromEnv(config);
|
||||
|
||||
setLoaded(false);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { themes } from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
// We use the same background colors here which GitHub uses.
|
||||
export const LIGHT_CARD_BG = "#ffffff";
|
||||
const DARK_CARD_BG = "#0d1117";
|
||||
|
||||
// Themes that look good in either mode (their text/icon colors read on both a
|
||||
// light and a dark backdrop), so their backdrop follows the app theme. Note we
|
||||
// only list themes that are genuinely mode-agnostic here: other transparent
|
||||
// themes (e.g. shadow_*) have dark text and must stay on a light backdrop.
|
||||
const ADAPTIVE_THEMES = ["transparent", "ambient_gradient"];
|
||||
|
||||
/** Expand a shorthand `#abc` hex to `#aabbcc`. */
|
||||
function expandHex(hex: string): string {
|
||||
if (hex.length === 3) {
|
||||
return hex
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/** Whether a hex color (3-, 6- or 8-digit) is dark by perceived luminance. */
|
||||
function isDarkHex(hex: string): boolean {
|
||||
const normalized = expandHex(hex);
|
||||
if (normalized.length < 6) {
|
||||
return false;
|
||||
}
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
const g = parseInt(normalized.slice(2, 4), 16);
|
||||
const b = parseInt(normalized.slice(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance < 0.5;
|
||||
}
|
||||
|
||||
/** Whether a theme's `bg_color` (hex or `angle,c1,c2,…` gradient) is dark. */
|
||||
function isDarkBg(bgColor: string): boolean {
|
||||
const parts = bgColor.split(",");
|
||||
// For gradients use the first color stop (index 0 is the angle).
|
||||
return isDarkHex((parts.length > 1 ? parts[1] : parts[0]) ?? "");
|
||||
}
|
||||
|
||||
/** A theme whose backdrop should follow the app mode rather than its own bg. */
|
||||
function isAdaptiveTheme(themeName: string): boolean {
|
||||
return ADAPTIVE_THEMES.includes(themeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the backdrop a card actually gets is dark, for the given app mode.
|
||||
* Adaptive themes follow the app mode; the rest follow their own background.
|
||||
*/
|
||||
function isCardBackdropDark(themeName: string, appIsDark: boolean): boolean {
|
||||
const theme = themes[themeName as keyof typeof themes];
|
||||
if (isAdaptiveTheme(themeName)) {
|
||||
return appIsDark;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!theme) {
|
||||
return appIsDark;
|
||||
}
|
||||
return isDarkBg(theme.bg_color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort rank for the theme grid: light themes first (0), adaptive themes in the
|
||||
* middle (1), dark themes last (2). Adaptive cards sit between the groups so
|
||||
* they blend with whichever side matches their backdrop in the current mode.
|
||||
*/
|
||||
export function getThemeSortRank(themeName: string): number {
|
||||
if (isAdaptiveTheme(themeName)) {
|
||||
return 1;
|
||||
}
|
||||
const theme = themes[themeName as keyof typeof themes];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!theme) {
|
||||
return 0;
|
||||
}
|
||||
return isDarkBg(theme.bg_color) ? 2 : 0;
|
||||
}
|
||||
|
||||
/** The backdrop color a card rendered with the given theme should sit on. */
|
||||
export function getCardThemeBackdrop(
|
||||
themeName: string,
|
||||
appIsDark: boolean,
|
||||
): string {
|
||||
return isCardBackdropDark(themeName, appIsDark)
|
||||
? DARK_CARD_BG
|
||||
: LIGHT_CARD_BG;
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { HTMLProps, JSX, ReactNode } from "react";
|
||||
|
||||
interface ButtonProps extends HTMLProps<HTMLButtonElement> {
|
||||
type ButtonVariant = "primary" | "soft" | "error";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, "size"> {
|
||||
children: ReactNode;
|
||||
/** DaisyUI color variant. Omit for the default neutral button. */
|
||||
variant?: ButtonVariant | undefined;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps): JSX.Element {
|
||||
const { className, children, ...rest } = props;
|
||||
const { className, children, variant, size = "md", ...rest } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
|
||||
"btn text-lg",
|
||||
{
|
||||
"btn-primary": variant === "primary",
|
||||
"btn-soft": variant === "soft",
|
||||
"btn-error": variant === "error",
|
||||
"btn-sm": size === "sm",
|
||||
"btn-lg": size === "lg",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Checkbox({
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
className="checkbox bg-white text-[#18181b] mr-2"
|
||||
className="checkbox checkbox-primary mr-2"
|
||||
onChange={() => {
|
||||
onCheckedChange(!checked);
|
||||
}}
|
||||
|
||||
@@ -27,10 +27,7 @@ export function Select({
|
||||
}: SelectProps): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
className={clsx(
|
||||
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
|
||||
className,
|
||||
)}
|
||||
className={clsx("text-base select select-sm w-40 mt-4", className)}
|
||||
value={selectedOption.value}
|
||||
onChange={(e) => {
|
||||
onOptionChange(options[e.target.selectedIndex] as SelectOption);
|
||||
@@ -43,7 +40,7 @@ export function Select({
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={clsx({
|
||||
"bg-blue-200": option.value === selectedOption.value,
|
||||
"bg-primary/20": option.value === selectedOption.value,
|
||||
})}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
FaCheck as CheckIcon,
|
||||
FaMoon as MoonIcon,
|
||||
FaSun as SunIcon,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||
import { THEMES } from "../../redux/slices/theme";
|
||||
|
||||
interface ThemeIconProps {
|
||||
isDark: boolean;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/** Sun for light themes, moon for dark ones. */
|
||||
function ThemeIcon({ isDark, className }: ThemeIconProps): JSX.Element {
|
||||
return isDark ? (
|
||||
<MoonIcon className={className} />
|
||||
) : (
|
||||
<SunIcon className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemePicker(): JSX.Element {
|
||||
const { theme, isDark, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, right: 0 });
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!open && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
setOpen((value) => !value);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
!menuRef.current?.contains(target) &&
|
||||
!buttonRef.current?.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", close);
|
||||
// Close on scroll so the fixed menu can't detach from its button.
|
||||
window.addEventListener("scroll", close, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-circle not-[&:hover]:text-current"
|
||||
aria-label="Choose theme"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title="Choose theme"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ThemeIcon isDark={isDark} className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<ul
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="menu bg-base-200 text-base-content rounded-box fixed z-[9999] w-48 p-2 shadow-lg"
|
||||
style={{ top: position.top, right: position.right }}
|
||||
>
|
||||
{THEMES.map((option) => (
|
||||
<li key={option.name}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={option.name === theme}
|
||||
data-theme-value={option.name}
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
setTheme(option.name);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ThemeIcon isDark={option.isDark} className="w-4 h-4" />
|
||||
{option.label}
|
||||
</span>
|
||||
{option.name === theme && <CheckIcon className="w-3 h-3" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const options: Array<SelectOption> = [
|
||||
id: 6,
|
||||
label: "Only Languages",
|
||||
disabled: false,
|
||||
value: "compact&hide_progress=true",
|
||||
value: "hide_progress",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
import { useDebouncedField } from "../../hooks/useDebouncedField";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface NumericSectionProps {
|
||||
@@ -26,45 +27,21 @@ export function NumericSection({
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: NumericSectionProps): JSX.Element {
|
||||
const [internalValue, setInternalValue] = useState(() => value?.toString());
|
||||
const debounceTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce onValueChange
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
if (internalValue === value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
debounceTimeout.current = window.setTimeout(() => {
|
||||
const maybeNumber = internalValue && parseInt(internalValue, 10);
|
||||
if (typeof maybeNumber !== "number" || Number.isNaN(maybeNumber)) {
|
||||
onValueChange(undefined);
|
||||
} else {
|
||||
onValueChange(maybeNumber);
|
||||
}
|
||||
}, 700);
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimeout.current as number);
|
||||
};
|
||||
}, [internalValue, onValueChange, value]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value?.toString());
|
||||
}, [value]);
|
||||
const { inputValue, setInputValue } = useDebouncedField({
|
||||
value,
|
||||
onValueChange,
|
||||
type: "number",
|
||||
});
|
||||
|
||||
return (
|
||||
<Section title={title}>
|
||||
<p>{description}</p>
|
||||
<input
|
||||
type="number"
|
||||
className="border border-gray-300 bg-white rounded px-2 py-1 mt-2 w-1/4"
|
||||
value={internalValue ?? ""}
|
||||
className="border border-base-content/20 rounded px-2 py-1 mt-2 w-1/4 bg-base-100"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInternalValue(e.target.value);
|
||||
setInputValue(e.target.value);
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
|
||||
@@ -24,7 +24,7 @@ function ProgressSection({
|
||||
<button
|
||||
className={clsx(
|
||||
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
|
||||
passed ? "border-blue-500" : "border-gray-500",
|
||||
passed ? "border-primary" : "border-base-content/60",
|
||||
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
|
||||
)}
|
||||
type="button"
|
||||
@@ -32,14 +32,16 @@ function ProgressSection({
|
||||
>
|
||||
<div
|
||||
className={clsx("text-lg font-bold", {
|
||||
"text-blue-500": passed,
|
||||
"text-gray-500": !passed,
|
||||
"text-primary": passed,
|
||||
"text-base-content/60": !passed,
|
||||
"-mt-[4px]": isActive,
|
||||
})}
|
||||
>
|
||||
{`Step ${num + 1}`}
|
||||
</div>
|
||||
<div className={passed ? "text-gray-700" : "text-gray-500"}>{item}</div>
|
||||
<div className={passed ? "text-base-content/80" : "text-base-content/60"}>
|
||||
{item}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -59,16 +61,23 @@ export function ProgressBar({
|
||||
const rightDisabled = currItemIndex === items.length - 1;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center sticky top-0 bg-gray-200 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
|
||||
<LeftArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-gray-400 cursor-not-allowed": leftDisabled,
|
||||
"text-gray-700 cursor-pointer": !leftDisabled,
|
||||
})}
|
||||
<div className="w-full flex items-center sticky top-0 bg-base-300 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous step"
|
||||
disabled={leftDisabled}
|
||||
className="btn btn-ghost btn-circle"
|
||||
onClick={() => {
|
||||
onItemClick(Math.max(currItemIndex - 1, 0));
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<LeftArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-base-content/30": leftDisabled,
|
||||
"text-base-content/70": !leftDisabled,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
<div className="px-2 flex-grow flex flex-row">
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
@@ -85,15 +94,22 @@ export function ProgressBar({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RightArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-gray-400 cursor-not-allowed": rightDisabled,
|
||||
"text-gray-700 cursor-pointer": !rightDisabled,
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next step"
|
||||
disabled={rightDisabled}
|
||||
className="btn btn-ghost btn-circle"
|
||||
onClick={() => {
|
||||
onItemClick(Math.min(currItemIndex + 1, items.length - 1));
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<RightArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-base-content/30": rightDisabled,
|
||||
"text-base-content/70": !rightDisabled,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ export function Section({ title, children }: SectionProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex relative pb-12">
|
||||
<div className="h-full w-10 absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-full w-1 bg-gray-200 pointer-events-none" />
|
||||
<div className="h-full w-1 bg-base-300 pointer-events-none" />
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-500 inline-flex items-center justify-center text-white relative z-10">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary inline-flex items-center justify-center text-primary-content relative z-10">
|
||||
<LightningIcon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-grow pl-4">
|
||||
<h2 className="font-medium title-font text-sm text-gray-900 mb-1 tracking-wider">
|
||||
<h2 className="font-medium title-font text-sm text-base-content mb-1 tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
|
||||
@@ -16,7 +16,7 @@ const options: Array<SelectOption> = [
|
||||
DEFAULT_OPTION,
|
||||
{ id: 2, label: "Percentile", value: "percentile", disabled: false },
|
||||
{ id: 3, label: "GitHub", value: "github", disabled: false },
|
||||
{ id: 4, label: "None", value: "default&hide_rank=true", disabled: false },
|
||||
{ id: 4, label: "None", value: "hide_rank", disabled: false },
|
||||
];
|
||||
|
||||
interface StatsRankSectionProps {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
|
||||
|
||||
import { useDebouncedField } from "../../hooks/useDebouncedField";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface TextSectionProps {
|
||||
@@ -25,29 +26,11 @@ export function TextSection({
|
||||
placeholder,
|
||||
onPaste,
|
||||
}: TextSectionProps): JSX.Element {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const debounceTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce onValueChange
|
||||
if (debounceTimeout.current) {
|
||||
window.clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
if (internalValue === value) {
|
||||
return undefined;
|
||||
}
|
||||
debounceTimeout.current = window.setTimeout(() => {
|
||||
onValueChange(internalValue);
|
||||
}, 700);
|
||||
// return cleanup function:
|
||||
return () => {
|
||||
window.clearTimeout(debounceTimeout.current as number);
|
||||
};
|
||||
}, [internalValue, onValueChange, value]);
|
||||
const { inputValue, setInputValue } = useDebouncedField({
|
||||
value,
|
||||
onValueChange,
|
||||
type: "text",
|
||||
});
|
||||
|
||||
return (
|
||||
<Section title={title}>
|
||||
@@ -55,12 +38,12 @@ export function TextSection({
|
||||
<input
|
||||
type="text"
|
||||
className={clsx(
|
||||
"border border-gray-300 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
|
||||
{ "cursor-not-allowed bg-gray-100": disabled, "bg-white": !disabled },
|
||||
"border border-base-content/20 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
|
||||
disabled ? "cursor-not-allowed bg-base-200" : "bg-base-100",
|
||||
)}
|
||||
value={internalValue}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInternalValue(e.target.value);
|
||||
setInputValue(e.target.value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -24,7 +24,7 @@ const options: Array<SelectOption> = [
|
||||
id: 3,
|
||||
label: "Text Only",
|
||||
disabled: false,
|
||||
value: "default&hide_progress=true&card_width=315",
|
||||
value: "hide_progress",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const USE_LOGGER = true as boolean;
|
||||
|
||||
export const CLIENT_ID = "Ov23lilAc5biyyRY0K1u";
|
||||
export const CLIENT_ID = "Ov23liZSweT9LJrck9i8";
|
||||
|
||||
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";
|
||||
@plugin "daisyui";
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
light --default,
|
||||
dark --prefersdark;
|
||||
}
|
||||
|
||||
/*
|
||||
Override primary color with default blue from github-stats-extended.
|
||||
Override background color in dark mode with GitHub's dark mode background color.
|
||||
*/
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
--color-primary: #2f80ed;
|
||||
--color-error: var(--color-red-700);
|
||||
}
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dark";
|
||||
--color-primary: #2f80ed;
|
||||
--color-error: var(--color-red-900);
|
||||
--color-base-100: #0d1117;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Snappy duration for interactive elements (buttons, hovers). */
|
||||
--transition-fast: 100ms;
|
||||
/* Slower, deliberate fade for large surfaces during a theme switch. */
|
||||
--transition-surface: 300ms;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Ubuntu, Sans-Serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Smoothly animate color changes when switching themes / on hover. */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
/*
|
||||
* An ease-out curve: animations start at full speed so quick interactions
|
||||
* like button hovers feel responsive instead of laggy.
|
||||
*/
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
transition-duration: var(--transition-fast);
|
||||
}
|
||||
|
||||
/*
|
||||
* Large surface backgrounds fade more slowly so a theme switch reads as a
|
||||
* deliberate fade rather than an instant flip. Durations reference the custom
|
||||
* properties above so the reduced-motion override below zeroes them out
|
||||
* regardless of selector specificity.
|
||||
*/
|
||||
body,
|
||||
.bg-base-100,
|
||||
.bg-base-200,
|
||||
.bg-base-300 {
|
||||
transition-duration: var(--transition-surface);
|
||||
}
|
||||
|
||||
/*
|
||||
* DaisyUI's `.btn` ships its own 200ms transition which felt slow. The global
|
||||
* rule above is unlayered, so it would otherwise clobber the button's property
|
||||
* list and timing function. Restore both here (so fill, text and border animate
|
||||
* together on outline buttons) and only speed up the duration.
|
||||
*/
|
||||
.btn {
|
||||
transition-property: color, background-color, border-color, box-shadow;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
transition-duration: var(--transition-fast);
|
||||
}
|
||||
|
||||
/*
|
||||
* DaisyUI's default hover is very subtle; make it noticeable. Primary always darkens,
|
||||
* while soft lightens in dark mode and darkens in light mode, so the change shows.
|
||||
*/
|
||||
.btn-primary:hover {
|
||||
background-color: color-mix(in oklab, var(--color-primary), #000 30%);
|
||||
}
|
||||
.btn-soft {
|
||||
background-color: color-mix(in oklab, var(--color-base-100), #888 35%);
|
||||
}
|
||||
.btn-soft:hover {
|
||||
background-color: color-mix(in oklab, var(--color-base-100), #888 60%);
|
||||
}
|
||||
.btn-error {
|
||||
color: var(--color-neutral-content);
|
||||
}
|
||||
.btn-error:hover {
|
||||
color: var(--color-neutral-content);
|
||||
background-color: color-mix(in oklab, var(--color-error), #f00 40%);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 0;
|
||||
border: var(--color-primary) solid 1px;
|
||||
}
|
||||
|
||||
/* Respect users who prefer reduced motion. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-fast: 0ms;
|
||||
--transition-surface: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,75 +4,45 @@
|
||||
"repositories": {
|
||||
"totalCount": 139,
|
||||
"nodes": [
|
||||
{ "name": "classicLogo", "stargazers": { "totalCount": 1 } },
|
||||
{ "name": "creativechat", "stargazers": { "totalCount": 1 } },
|
||||
{ "name": "verlet_tests", "stargazers": { "totalCount": 1 } },
|
||||
{
|
||||
"name": "blade-checkout-ui-demo",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{
|
||||
"name": "tslint-plugin-analytics",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "stargazer", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "react-smooth-range-input",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{
|
||||
"name": "csb-git-integration-bug",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "covid-nepal-web", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "shrijan00003.github.io",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "TheBigCB.com", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "html2commonmark", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "test-myaction", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "react-emoji-render", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "sajilo-editor", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "coder-aayush.github.io",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "codesandbox-client", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "product-sans", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "NodejsBlogSystem", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "native-javascript", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "p5.js", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "gatsbytutorials.com", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "hello-world-docker-action",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "glugmvit.github.io", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "react-map-interaction",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "react-external-image", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "gatsby-boilerplate", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "YouBackup", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "npm-expansions", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "Toy-Neural-Network-JS",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "LSystemCreator", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "Happy-2019", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "jquery-navScroll", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "strml.net", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "Logo", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "website", "stargazers": { "totalCount": 0 } },
|
||||
{
|
||||
"name": "anuraghazra.github.io-old",
|
||||
"stargazers": { "totalCount": 0 }
|
||||
},
|
||||
{ "name": "Quintus", "stargazers": { "totalCount": 0 } },
|
||||
{ "name": "Electron_experiments", "stargazers": { "totalCount": 0 } }
|
||||
{ "name": "classicLogo", "stargazerCount": 1 },
|
||||
{ "name": "creativechat", "stargazerCount": 1 },
|
||||
{ "name": "verlet_tests", "stargazerCount": 1 },
|
||||
{ "name": "blade-checkout-ui-demo", "stargazerCount": 0 },
|
||||
{ "name": "tslint-plugin-analytics", "stargazerCount": 0 },
|
||||
{ "name": "stargazer", "stargazerCount": 0 },
|
||||
{ "name": "react-smooth-range-input", "stargazerCount": 0 },
|
||||
{ "name": "csb-git-integration-bug", "stargazerCount": 0 },
|
||||
{ "name": "covid-nepal-web", "stargazerCount": 0 },
|
||||
{ "name": "shrijan00003.github.io", "stargazerCount": 0 },
|
||||
{ "name": "TheBigCB.com", "stargazerCount": 0 },
|
||||
{ "name": "html2commonmark", "stargazerCount": 0 },
|
||||
{ "name": "test-myaction", "stargazerCount": 0 },
|
||||
{ "name": "react-emoji-render", "stargazerCount": 0 },
|
||||
{ "name": "sajilo-editor", "stargazerCount": 0 },
|
||||
{ "name": "coder-aayush.github.io", "stargazerCount": 0 },
|
||||
{ "name": "codesandbox-client", "stargazerCount": 0 },
|
||||
{ "name": "product-sans", "stargazerCount": 0 },
|
||||
{ "name": "NodejsBlogSystem", "stargazerCount": 0 },
|
||||
{ "name": "native-javascript", "stargazerCount": 0 },
|
||||
{ "name": "p5.js", "stargazerCount": 0 },
|
||||
{ "name": "gatsbytutorials.com", "stargazerCount": 0 },
|
||||
{ "name": "hello-world-docker-action", "stargazerCount": 0 },
|
||||
{ "name": "glugmvit.github.io", "stargazerCount": 0 },
|
||||
{ "name": "react-map-interaction", "stargazerCount": 0 },
|
||||
{ "name": "react-external-image", "stargazerCount": 0 },
|
||||
{ "name": "gatsby-boilerplate", "stargazerCount": 0 },
|
||||
{ "name": "YouBackup", "stargazerCount": 0 },
|
||||
{ "name": "npm-expansions", "stargazerCount": 0 },
|
||||
{ "name": "Toy-Neural-Network-JS", "stargazerCount": 0 },
|
||||
{ "name": "LSystemCreator", "stargazerCount": 0 },
|
||||
{ "name": "Happy-2019", "stargazerCount": 0 },
|
||||
{ "name": "jquery-navScroll", "stargazerCount": 0 },
|
||||
{ "name": "strml.net", "stargazerCount": 0 },
|
||||
{ "name": "Logo", "stargazerCount": 0 },
|
||||
{ "name": "website", "stargazerCount": 0 },
|
||||
{ "name": "anuraghazra.github.io-old", "stargazerCount": 0 },
|
||||
{ "name": "Quintus", "stargazerCount": 0 },
|
||||
{ "name": "Electron_experiments", "stargazerCount": 0 }
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
"isPrivate": false,
|
||||
"isArchived": false,
|
||||
"isTemplate": false,
|
||||
"stargazers": {
|
||||
"totalCount": 76994
|
||||
},
|
||||
"stargazerCount": 76994,
|
||||
"description": ":zap: Dynamically generated stats for your github readmes",
|
||||
"primaryLanguage": {
|
||||
"color": "#f1e05a",
|
||||
|
||||
@@ -36,606 +36,109 @@
|
||||
"repositories": {
|
||||
"totalCount": 139,
|
||||
"nodes": [
|
||||
{
|
||||
"name": "github-readme-stats",
|
||||
"stargazers": {
|
||||
"totalCount": 77025
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Verly.js",
|
||||
"stargazers": {
|
||||
"totalCount": 696
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "anuraghazra.github.io",
|
||||
"stargazers": {
|
||||
"totalCount": 608
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "anuraghazra",
|
||||
"stargazers": {
|
||||
"totalCount": 376
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type-trident",
|
||||
"stargazers": {
|
||||
"totalCount": 359
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BugVilla",
|
||||
"stargazers": {
|
||||
"totalCount": 232
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "convoychat",
|
||||
"stargazers": {
|
||||
"totalCount": 214
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CanvasFun",
|
||||
"stargazers": {
|
||||
"totalCount": 211
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "EvolutionAquerium",
|
||||
"stargazers": {
|
||||
"totalCount": 192
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "typelevel-parser",
|
||||
"stargazers": {
|
||||
"totalCount": 147
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VerlyRangeSlider",
|
||||
"stargazers": {
|
||||
"totalCount": 135
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vscode-strip-ts-copy",
|
||||
"stargazers": {
|
||||
"totalCount": 72
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-folder-tree",
|
||||
"stargazers": {
|
||||
"totalCount": 54
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Atomic.js",
|
||||
"stargazers": {
|
||||
"totalCount": 53
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ToyLang",
|
||||
"stargazers": {
|
||||
"totalCount": 51
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "design-patterns-everyday",
|
||||
"stargazers": {
|
||||
"totalCount": 46
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby",
|
||||
"stargazers": {
|
||||
"totalCount": 36
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Slime",
|
||||
"stargazers": {
|
||||
"totalCount": 35
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Candy.js",
|
||||
"stargazers": {
|
||||
"totalCount": 34
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ShaderExpo",
|
||||
"stargazers": {
|
||||
"totalCount": 33
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Nothing",
|
||||
"stargazers": {
|
||||
"totalCount": 32
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "peerlist-profile-action",
|
||||
"stargazers": {
|
||||
"totalCount": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "facebook-reaction-animation",
|
||||
"stargazers": {
|
||||
"totalCount": 29
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-stripe-dropdown",
|
||||
"stargazers": {
|
||||
"totalCount": 27
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "QuickerPoll",
|
||||
"stargazers": {
|
||||
"totalCount": 27
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "playground-format-on-save",
|
||||
"stargazers": {
|
||||
"totalCount": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ParticleBrush",
|
||||
"stargazers": {
|
||||
"totalCount": 23
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graphql-oauth",
|
||||
"stargazers": {
|
||||
"totalCount": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "parasites",
|
||||
"stargazers": {
|
||||
"totalCount": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "twitter-banner-jokes",
|
||||
"stargazers": {
|
||||
"totalCount": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "verly-cpp",
|
||||
"stargazers": {
|
||||
"totalCount": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-github-issues-blog",
|
||||
"stargazers": {
|
||||
"totalCount": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "NikeHyperAce",
|
||||
"stargazers": {
|
||||
"totalCount": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rust-particles",
|
||||
"stargazers": {
|
||||
"totalCount": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VerletDrawing",
|
||||
"stargazers": {
|
||||
"totalCount": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graphql-jwt-auth-test",
|
||||
"stargazers": {
|
||||
"totalCount": 9
|
||||
}
|
||||
},
|
||||
{ "name": "github-readme-stats", "stargazerCount": 77025 },
|
||||
{ "name": "Verly.js", "stargazerCount": 696 },
|
||||
{ "name": "anuraghazra.github.io", "stargazerCount": 608 },
|
||||
{ "name": "anuraghazra", "stargazerCount": 376 },
|
||||
{ "name": "type-trident", "stargazerCount": 359 },
|
||||
{ "name": "BugVilla", "stargazerCount": 232 },
|
||||
{ "name": "convoychat", "stargazerCount": 214 },
|
||||
{ "name": "CanvasFun", "stargazerCount": 211 },
|
||||
{ "name": "EvolutionAquerium", "stargazerCount": 192 },
|
||||
{ "name": "typelevel-parser", "stargazerCount": 147 },
|
||||
{ "name": "VerlyRangeSlider", "stargazerCount": 135 },
|
||||
{ "name": "vscode-strip-ts-copy", "stargazerCount": 72 },
|
||||
{ "name": "react-folder-tree", "stargazerCount": 54 },
|
||||
{ "name": "Atomic.js", "stargazerCount": 53 },
|
||||
{ "name": "ToyLang", "stargazerCount": 51 },
|
||||
{ "name": "design-patterns-everyday", "stargazerCount": 46 },
|
||||
{ "name": "gatsby", "stargazerCount": 36 },
|
||||
{ "name": "Slime", "stargazerCount": 35 },
|
||||
{ "name": "Candy.js", "stargazerCount": 34 },
|
||||
{ "name": "ShaderExpo", "stargazerCount": 33 },
|
||||
{ "name": "Nothing", "stargazerCount": 32 },
|
||||
{ "name": "peerlist-profile-action", "stargazerCount": 30 },
|
||||
{ "name": "facebook-reaction-animation", "stargazerCount": 29 },
|
||||
{ "name": "react-stripe-dropdown", "stargazerCount": 27 },
|
||||
{ "name": "QuickerPoll", "stargazerCount": 27 },
|
||||
{ "name": "playground-format-on-save", "stargazerCount": 24 },
|
||||
{ "name": "ParticleBrush", "stargazerCount": 23 },
|
||||
{ "name": "graphql-oauth", "stargazerCount": 21 },
|
||||
{ "name": "parasites", "stargazerCount": 21 },
|
||||
{ "name": "twitter-banner-jokes", "stargazerCount": 18 },
|
||||
{ "name": "verly-cpp", "stargazerCount": 16 },
|
||||
{ "name": "gatsby-github-issues-blog", "stargazerCount": 16 },
|
||||
{ "name": "NikeHyperAce", "stargazerCount": 12 },
|
||||
{ "name": "rust-particles", "stargazerCount": 11 },
|
||||
{ "name": "VerletDrawing", "stargazerCount": 10 },
|
||||
{ "name": "graphql-jwt-auth-test", "stargazerCount": 9 },
|
||||
{
|
||||
"name": "anuraghazra.github.io-old-react",
|
||||
"stargazers": {
|
||||
"totalCount": 9
|
||||
}
|
||||
"stargazerCount": 9
|
||||
},
|
||||
{
|
||||
"name": "react18-ssr-test",
|
||||
"stargazers": {
|
||||
"totalCount": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GyroDodge",
|
||||
"stargazers": {
|
||||
"totalCount": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "go-phyllotaxis",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graphql-oauth-client",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code-runner-action",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GithubActionsPlayground",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Loader.js",
|
||||
"stargazers": {
|
||||
"totalCount": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ts-essentials",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chakra-ui",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "text-to-handwriting",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "build-your-own-x",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nodejs-dynamic-restapi",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DevYouTubeList",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "FlockingBlackHole",
|
||||
"stargazers": {
|
||||
"totalCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "webgpu-boids",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-githubblock",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "renderless-components",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reakit",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "micro-open-graph",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rest-and-graphql",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "abell-issues-blog",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VanillaMVC",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "covid19-sim",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "YoutubeDownloader",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "verlet.js",
|
||||
"stargazers": {
|
||||
"totalCount": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-github-ts",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tw-dynamic-interpolation",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ts-string-interpolator",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "AnuReact",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CanvasBalls",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "circleci-test",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-starter-netlify-cms",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "WebGL.js",
|
||||
"stargazers": {
|
||||
"totalCount": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-fork-workflow",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test-codesandbox-projects",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "codesandbox-template-astro",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apollo",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "api-docs",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Buddies-movie",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2k76",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-remark-embedder",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "weird-hello-worlds",
|
||||
"stargazers": {
|
||||
"totalCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rn-tabview-test-repro",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tailwindcss-jit",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DefinitelyTyped",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-components-demo",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volant-vo",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-spectrum",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "webhooks-test",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-showdown",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transcode",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "awake-yet-action",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bradgarropy.com",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gatsby-plugin-social-banners",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tour360",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blizard.js",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keep-a-changelog",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vulnerabilities",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "harleydavidson",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "awesome-webgl",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "NewtonsInterpolation",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "now-github-starter",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "simplerockets",
|
||||
"stargazers": {
|
||||
"totalCount": 1
|
||||
}
|
||||
}
|
||||
{ "name": "react18-ssr-test", "stargazerCount": 8 },
|
||||
{ "name": "GyroDodge", "stargazerCount": 8 },
|
||||
{ "name": "go-phyllotaxis", "stargazerCount": 6 },
|
||||
{ "name": "graphql-oauth-client", "stargazerCount": 6 },
|
||||
{ "name": "code-runner-action", "stargazerCount": 6 },
|
||||
{ "name": "GithubActionsPlayground", "stargazerCount": 6 },
|
||||
{ "name": "Loader.js", "stargazerCount": 6 },
|
||||
{ "name": "ts-essentials", "stargazerCount": 5 },
|
||||
{ "name": "chakra-ui", "stargazerCount": 5 },
|
||||
{ "name": "text-to-handwriting", "stargazerCount": 5 },
|
||||
{ "name": "build-your-own-x", "stargazerCount": 5 },
|
||||
{ "name": "nodejs-dynamic-restapi", "stargazerCount": 5 },
|
||||
{ "name": "DevYouTubeList", "stargazerCount": 5 },
|
||||
{ "name": "FlockingBlackHole", "stargazerCount": 5 },
|
||||
{ "name": "webgpu-boids", "stargazerCount": 4 },
|
||||
{ "name": "test-githubblock", "stargazerCount": 4 },
|
||||
{ "name": "renderless-components", "stargazerCount": 4 },
|
||||
{ "name": "reakit", "stargazerCount": 4 },
|
||||
{ "name": "micro-open-graph", "stargazerCount": 4 },
|
||||
{ "name": "rest-and-graphql", "stargazerCount": 4 },
|
||||
{ "name": "abell-issues-blog", "stargazerCount": 4 },
|
||||
{ "name": "VanillaMVC", "stargazerCount": 4 },
|
||||
{ "name": "covid19-sim", "stargazerCount": 4 },
|
||||
{ "name": "YoutubeDownloader", "stargazerCount": 4 },
|
||||
{ "name": "verlet.js", "stargazerCount": 4 },
|
||||
{ "name": "test-github-ts", "stargazerCount": 3 },
|
||||
{ "name": "tw-dynamic-interpolation", "stargazerCount": 3 },
|
||||
{ "name": "ts-string-interpolator", "stargazerCount": 3 },
|
||||
{ "name": "AnuReact", "stargazerCount": 3 },
|
||||
{ "name": "CanvasBalls", "stargazerCount": 3 },
|
||||
{ "name": "circleci-test", "stargazerCount": 3 },
|
||||
{ "name": "gatsby-starter-netlify-cms", "stargazerCount": 3 },
|
||||
{ "name": "WebGL.js", "stargazerCount": 3 },
|
||||
{ "name": "test-fork-workflow", "stargazerCount": 2 },
|
||||
{ "name": "test-codesandbox-projects", "stargazerCount": 2 },
|
||||
{ "name": "codesandbox-template-astro", "stargazerCount": 2 },
|
||||
{ "name": "apollo", "stargazerCount": 2 },
|
||||
{ "name": "api-docs", "stargazerCount": 2 },
|
||||
{ "name": "Buddies-movie", "stargazerCount": 2 },
|
||||
{ "name": "2k76", "stargazerCount": 2 },
|
||||
{ "name": "gatsby-remark-embedder", "stargazerCount": 2 },
|
||||
{ "name": "weird-hello-worlds", "stargazerCount": 2 },
|
||||
{ "name": "rn-tabview-test-repro", "stargazerCount": 1 },
|
||||
{ "name": "tailwindcss-jit", "stargazerCount": 1 },
|
||||
{ "name": "DefinitelyTyped", "stargazerCount": 1 },
|
||||
{ "name": "server-components-demo", "stargazerCount": 1 },
|
||||
{ "name": "volant-vo", "stargazerCount": 1 },
|
||||
{ "name": "react-spectrum", "stargazerCount": 1 },
|
||||
{ "name": "webhooks-test", "stargazerCount": 1 },
|
||||
{ "name": "react-showdown", "stargazerCount": 1 },
|
||||
{ "name": "transcode", "stargazerCount": 1 },
|
||||
{ "name": "awake-yet-action", "stargazerCount": 1 },
|
||||
{ "name": "bradgarropy.com", "stargazerCount": 1 },
|
||||
{ "name": "gatsby-plugin-social-banners", "stargazerCount": 1 },
|
||||
{ "name": "Tour360", "stargazerCount": 1 },
|
||||
{ "name": "blizard.js", "stargazerCount": 1 },
|
||||
{ "name": "keep-a-changelog", "stargazerCount": 1 },
|
||||
{ "name": "vulnerabilities", "stargazerCount": 1 },
|
||||
{ "name": "harleydavidson", "stargazerCount": 1 },
|
||||
{ "name": "awesome-webgl", "stargazerCount": 1 },
|
||||
{ "name": "NewtonsInterpolation", "stargazerCount": 1 },
|
||||
{ "name": "now-github-starter", "stargazerCount": 1 },
|
||||
{ "name": "simplerockets", "stargazerCount": 1 }
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CardType } from "./CardType";
|
||||
import { cardUrl } from "./CardUrl";
|
||||
|
||||
describe("cardUrl", () => {
|
||||
it("maps each card type to its path", () => {
|
||||
expect(cardUrl(CardType.STATS).toString()).toBe("");
|
||||
expect(cardUrl(CardType.TOP_LANGS).toString()).toBe("/top-langs");
|
||||
expect(cardUrl(CardType.PIN).toString()).toBe("/pin");
|
||||
expect(cardUrl(CardType.GIST).toString()).toBe("/gist");
|
||||
expect(cardUrl(CardType.WAKATIME).toString()).toBe("/wakatime");
|
||||
});
|
||||
|
||||
it("builds a suffix with params", () => {
|
||||
expect(
|
||||
cardUrl(CardType.TOP_LANGS).username("john").langsCount(4).toString(),
|
||||
).toBe("/top-langs?username=john&langs_count=4");
|
||||
});
|
||||
|
||||
it("derives a download filename per card", () => {
|
||||
expect(cardUrl(CardType.STATS).username("john").filename()).toBe(
|
||||
"john_card",
|
||||
);
|
||||
expect(cardUrl(CardType.TOP_LANGS).username("john").filename()).toBe(
|
||||
"john_card",
|
||||
);
|
||||
expect(cardUrl(CardType.PIN).repo("owner/repo1").filename()).toBe(
|
||||
"owner/repo1_card",
|
||||
);
|
||||
expect(cardUrl(CardType.GIST).gistId("abc").filename()).toBe("gist_card");
|
||||
expect(cardUrl(CardType.WAKATIME).username("waka").filename()).toBe(
|
||||
"waka_card",
|
||||
);
|
||||
});
|
||||
|
||||
it("is immutable: setters return new instances", () => {
|
||||
const base = cardUrl(CardType.STATS).username("john");
|
||||
const withIcons = base.showIcons();
|
||||
|
||||
expect(base.toString()).toBe("?username=john");
|
||||
expect(withIcons.toString()).toBe("?username=john&show_icons=true");
|
||||
});
|
||||
|
||||
it("sets values verbatim (callers decide whether to include a param)", () => {
|
||||
// no dropping of empty/false — the value is set as given
|
||||
expect(cardUrl(CardType.STATS).username("").toString()).toBe("?username=");
|
||||
});
|
||||
|
||||
it("encodes special characters", () => {
|
||||
// spaces as %20 (not +), commas as %2C
|
||||
expect(
|
||||
cardUrl(CardType.STATS)
|
||||
.username("john")
|
||||
.customTitle("My Stats")
|
||||
.toString(),
|
||||
).toBe("?username=john&custom_title=My%20Stats");
|
||||
expect(
|
||||
cardUrl(CardType.STATS).username("john").show("a,b,c").toString(),
|
||||
).toBe("?username=john&show=a%2Cb%2Cc");
|
||||
});
|
||||
|
||||
it("builds an absolute api url with the given host", () => {
|
||||
expect(
|
||||
cardUrl(CardType.STATS)
|
||||
.username("john")
|
||||
.client("wizard")
|
||||
.toApiUrl("example.com"),
|
||||
).toBe("https://example.com/api?username=john&client=wizard");
|
||||
});
|
||||
|
||||
it("applies a theme via the universal setter", () => {
|
||||
expect(
|
||||
cardUrl(CardType.STATS).username("john").theme("github_dark").toString(),
|
||||
).toBe("?username=john&theme=github_dark");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { CardType } from "./CardType";
|
||||
|
||||
/**
|
||||
* Immutable, per-card builders for the query-string suffix of a card URL.
|
||||
*
|
||||
* Each card type accepts a different set of query params, so there is one
|
||||
* builder class per card exposing only the params that card supports. Setters
|
||||
* return a new instance (immutable); {@link CardUrlBase.toString} produces the
|
||||
* relative suffix (e.g. `/top-langs?username=foo&langs_count=4`).
|
||||
*
|
||||
* Which card accepts which param:
|
||||
*
|
||||
* | param | STATS | TOP_LANGS | PIN | GIST | WAKATIME |
|
||||
* | ----------------------- | :---: | :-------: | :-: | :--: | :------: |
|
||||
* | username | ✓ | ✓ | ✓ | | ✓ |
|
||||
* | hide_title | ✓ | ✓ | | | ✓ |
|
||||
* | custom_title | ✓ | | | | ✓ |
|
||||
* | rank_icon | ✓ | | | | |
|
||||
* | hide_rank | ✓ | | | | |
|
||||
* | show | ✓ | | | | |
|
||||
* | show_icons | ✓ | | | | |
|
||||
* | include_all_commits | ✓ | | | | |
|
||||
* | hide_values | | ✓ | | | |
|
||||
* | layout | | ✓ | | | ✓ |
|
||||
* | hide_progress | | ✓ | | | ✓ |
|
||||
* | langs_count | | ✓ | | | ✓ |
|
||||
* | repo | | | ✓ | | |
|
||||
* | description_lines_count | | | ✓ | | |
|
||||
* | show_owner | | | ✓ | ✓ | |
|
||||
* | id (gist) | | | | ✓ | |
|
||||
* | display_format | | | | | ✓ |
|
||||
* | card_width | | | | | ✓ |
|
||||
* | theme | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
* | client | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
* | disable_animations | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
*/
|
||||
abstract class CardUrlBase<S extends CardUrlBase<S>> {
|
||||
protected constructor(
|
||||
protected readonly params: ReadonlyMap<string, string>,
|
||||
) {}
|
||||
|
||||
/** Build a new instance of the concrete card builder. */
|
||||
protected abstract create(params: ReadonlyMap<string, string>): S;
|
||||
|
||||
/** Path segment appended after `/api` (empty for the stats card). */
|
||||
protected abstract readonly path: string;
|
||||
|
||||
/**
|
||||
* The single place that produces a new instance. Sets the param verbatim;
|
||||
* callers decide whether a param should be included (see `buildCardUrl`).
|
||||
*/
|
||||
protected with(key: string, value: string | number | boolean): S {
|
||||
const next = new Map(this.params);
|
||||
next.set(key, String(value));
|
||||
return this.create(next);
|
||||
}
|
||||
|
||||
// ---- universal setters (valid on every card) ----
|
||||
theme(v: string): S {
|
||||
return this.with("theme", v);
|
||||
}
|
||||
client(v: string): S {
|
||||
return this.with("client", v);
|
||||
}
|
||||
disableAnimations(v = true): S {
|
||||
return this.with("disable_animations", v);
|
||||
}
|
||||
|
||||
/** Suggested download filename (without extension) for this card. */
|
||||
abstract filename(): string;
|
||||
|
||||
/** Relative suffix, e.g. `/top-langs?username=foo&langs_count=4`. */
|
||||
toString(): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [k, v] of this.params) {
|
||||
search.set(k, v);
|
||||
}
|
||||
// URLSearchParams encodes spaces as `+`; normalize to `%20` for parity with
|
||||
// encodeURIComponent and universal query parsing.
|
||||
const qs = search.toString().replace(/\+/g, "%20");
|
||||
return `${this.path}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL the `<img>`/SvgInline loads. `host` is passed in so this
|
||||
* module stays free of the `window`-dependent constants.
|
||||
*/
|
||||
toApiUrl(host: string): string {
|
||||
return `https://${host}/api${this.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
class StatsCardUrl extends CardUrlBase<StatsCardUrl> {
|
||||
protected readonly path = "";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new StatsCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new StatsCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("username") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
rankIcon(v: string) {
|
||||
return this.with("rank_icon", v);
|
||||
}
|
||||
hideRank(v = true) {
|
||||
return this.with("hide_rank", v);
|
||||
}
|
||||
hideTitle(v = true) {
|
||||
return this.with("hide_title", v);
|
||||
}
|
||||
customTitle(v: string) {
|
||||
return this.with("custom_title", v);
|
||||
}
|
||||
show(v: string) {
|
||||
return this.with("show", v);
|
||||
}
|
||||
showIcons(v = true) {
|
||||
return this.with("show_icons", v);
|
||||
}
|
||||
includeAllCommits(v = true) {
|
||||
return this.with("include_all_commits", v);
|
||||
}
|
||||
}
|
||||
|
||||
class TopLangsCardUrl extends CardUrlBase<TopLangsCardUrl> {
|
||||
protected readonly path = "/top-langs";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new TopLangsCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new TopLangsCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("username") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
layout(v: string) {
|
||||
return this.with("layout", v);
|
||||
}
|
||||
hideProgress(v = true) {
|
||||
return this.with("hide_progress", v);
|
||||
}
|
||||
hideTitle(v = true) {
|
||||
return this.with("hide_title", v);
|
||||
}
|
||||
langsCount(v: number) {
|
||||
return this.with("langs_count", v);
|
||||
}
|
||||
hideValues(v = true) {
|
||||
return this.with("hide_values", v);
|
||||
}
|
||||
}
|
||||
|
||||
class PinCardUrl extends CardUrlBase<PinCardUrl> {
|
||||
protected readonly path = "/pin";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new PinCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new PinCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("repo") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
repo(v: string) {
|
||||
return this.with("repo", v);
|
||||
}
|
||||
showOwner(v = true) {
|
||||
return this.with("show_owner", v);
|
||||
}
|
||||
descriptionLines(v: number) {
|
||||
return this.with("description_lines_count", v);
|
||||
}
|
||||
}
|
||||
|
||||
class GistCardUrl extends CardUrlBase<GistCardUrl> {
|
||||
protected readonly path = "/gist";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new GistCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new GistCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return "gist_card";
|
||||
}
|
||||
|
||||
gistId(v: string) {
|
||||
return this.with("id", v);
|
||||
}
|
||||
showOwner(v = true) {
|
||||
return this.with("show_owner", v);
|
||||
}
|
||||
}
|
||||
|
||||
class WakatimeCardUrl extends CardUrlBase<WakatimeCardUrl> {
|
||||
protected readonly path = "/wakatime";
|
||||
protected create(p: ReadonlyMap<string, string>) {
|
||||
return new WakatimeCardUrl(p);
|
||||
}
|
||||
static create() {
|
||||
return new WakatimeCardUrl(new Map());
|
||||
}
|
||||
|
||||
filename() {
|
||||
return `${this.params.get("username") ?? ""}_card`;
|
||||
}
|
||||
|
||||
username(v: string) {
|
||||
return this.with("username", v);
|
||||
}
|
||||
layout(v: string) {
|
||||
return this.with("layout", v);
|
||||
}
|
||||
hideProgress(v = true) {
|
||||
return this.with("hide_progress", v);
|
||||
}
|
||||
hideTitle(v = true) {
|
||||
return this.with("hide_title", v);
|
||||
}
|
||||
customTitle(v: string) {
|
||||
return this.with("custom_title", v);
|
||||
}
|
||||
langsCount(v: number) {
|
||||
return this.with("langs_count", v);
|
||||
}
|
||||
displayFormat(v: string) {
|
||||
return this.with("display_format", v);
|
||||
}
|
||||
cardWidth(v: number) {
|
||||
return this.with("card_width", v);
|
||||
}
|
||||
}
|
||||
|
||||
/** Any card-URL builder, regardless of card type (all share the universal API). */
|
||||
export type CardUrlBuilder =
|
||||
StatsCardUrl | TopLangsCardUrl | PinCardUrl | GistCardUrl | WakatimeCardUrl;
|
||||
|
||||
/**
|
||||
* Create a card-URL builder typed to the given card, so only the params that
|
||||
* card supports are in scope (e.g. `cardUrl(CardType.GIST)` has no
|
||||
* `langsCount`).
|
||||
*/
|
||||
export function cardUrl(c: typeof CardType.STATS): StatsCardUrl;
|
||||
export function cardUrl(c: typeof CardType.TOP_LANGS): TopLangsCardUrl;
|
||||
export function cardUrl(c: typeof CardType.PIN): PinCardUrl;
|
||||
export function cardUrl(c: typeof CardType.GIST): GistCardUrl;
|
||||
export function cardUrl(c: typeof CardType.WAKATIME): WakatimeCardUrl;
|
||||
export function cardUrl(c: CardType) {
|
||||
switch (c) {
|
||||
case CardType.STATS:
|
||||
return StatsCardUrl.create();
|
||||
case CardType.TOP_LANGS:
|
||||
return TopLangsCardUrl.create();
|
||||
case CardType.PIN:
|
||||
return PinCardUrl.create();
|
||||
case CardType.GIST:
|
||||
return GistCardUrl.create();
|
||||
case CardType.WAKATIME:
|
||||
return WakatimeCardUrl.create();
|
||||
default:
|
||||
c satisfies never;
|
||||
throw new Error(`unknown card type: ${c as string}`);
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,34 @@ export const STAGE_LABELS = [
|
||||
{
|
||||
title: "Login",
|
||||
shortTitle: "Login",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
title: "Select a Card",
|
||||
shortTitle: "Select Card",
|
||||
description: "You will be able to customize your card in future steps.",
|
||||
},
|
||||
{
|
||||
title: "Modify Card Parameters",
|
||||
shortTitle: "Modify Parameters",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
title: "Choose a Theme",
|
||||
shortTitle: "Select Theme",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
title: "Display your Card",
|
||||
shortTitle: "Display Card",
|
||||
description:
|
||||
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
|
||||
},
|
||||
] as const satisfies Array<{ title: string; shortTitle: string }>;
|
||||
] as const satisfies Array<{
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
export type StageIndex = {
|
||||
[K in keyof typeof STAGE_LABELS]: K extends `${infer N extends number}`
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { ToastContainer, toast } from "react-toastify";
|
||||
|
||||
import { getUserMetadata } from "../../api/user";
|
||||
import { clearAxiosCache } from "../../axios-override";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
useUserKey,
|
||||
@@ -56,6 +57,7 @@ export function AppTrends() {
|
||||
const userKey = useUserKey();
|
||||
const userToken = useUserToken();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
|
||||
|
||||
@@ -79,24 +81,20 @@ export function AppTrends() {
|
||||
clearAxiosCache();
|
||||
}, [userToken]);
|
||||
|
||||
/*
|
||||
* Advance to step 1 only when `isAuthenticated` transitions, so a logged-in user can still navigate back to step 0.
|
||||
* Tracked during render (comparing the previous value) rather than in an effect:
|
||||
* https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
*/
|
||||
{
|
||||
/*
|
||||
* This effect must only be executed when `isAuthenticated` changes,
|
||||
* otherwise logged-in user are unable to go back to step 1.
|
||||
*/
|
||||
const prevIsAuthenticated = useRef(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevIsAuthenticated.current === isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevIsAuthenticated.current = isAuthenticated;
|
||||
|
||||
const [prevIsAuthenticated, setPrevIsAuthenticated] =
|
||||
useState(isAuthenticated);
|
||||
if (isAuthenticated !== prevIsAuthenticated) {
|
||||
setPrevIsAuthenticated(isAuthenticated);
|
||||
if (isAuthenticated && stage === 0) {
|
||||
setStage(1);
|
||||
}
|
||||
}, [isAuthenticated, stage]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,9 +114,9 @@ export function AppTrends() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header currStageIndex={stage} onStageIndexChange={setStage} />
|
||||
<section className="bg-white text-gray-700 flex-grow">
|
||||
<section className="bg-base-100 text-base-content flex-grow">
|
||||
<HomeScreen stage={stage} setStage={setStage} />
|
||||
<ToastContainer />
|
||||
<ToastContainer theme={isDark ? "dark" : "light"} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JSX } from "react";
|
||||
import { FaGithub as GithubIcon } from "react-icons/fa";
|
||||
|
||||
import appIcon from "../../assets/appLogo64.png";
|
||||
import { ThemePicker } from "../../components/Generic/ThemePicker";
|
||||
import { ProgressBar } from "../../components/Home/Progress";
|
||||
import { STAGE_LABELS } from "../../models/Stage";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
@@ -19,31 +20,34 @@ export function Header({
|
||||
}: HeaderProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="text-gray-100 bg-gray-800 shadow-md z-50">
|
||||
<div
|
||||
className="text-neutral-content shadow-md z-50"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in oklab, var(--color-primary), #000 50%)",
|
||||
}}
|
||||
>
|
||||
<div className="px-5 py-2 flex flex-wrap">
|
||||
{/* Logo */}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center title-font font-medium text-gray-50 mb-0 md:mr-8"
|
||||
className="flex items-center title-font font-medium text-neutral-content mb-0 md:mr-8"
|
||||
>
|
||||
<img src={appIcon} alt="logo" className="w-6 h-6" />
|
||||
<span className="ml-2 text-xl">GitHub Stats Extended</span>
|
||||
</a>
|
||||
{/* Star on GitHub */}
|
||||
<div className="flex ml-auto items-center text-base justify-center">
|
||||
{/* Star on GitHub + theme toggle */}
|
||||
<div className="flex ml-auto items-center gap-2 text-base justify-center">
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-[0.25rem] shadow bg-neutral-content hover:opacity-90 text-neutral px-3 py-1 flex items-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[0.25rem] shadow bg-gray-200 hover:bg-gray-300 text-black px-3 py-1 flex items-center"
|
||||
>
|
||||
Star on
|
||||
<GithubIcon className="ml-1.5 w-5 h-5" />
|
||||
</button>
|
||||
Star on
|
||||
<GithubIcon className="ml-1.5 w-5 h-5" />
|
||||
</a>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { BounceLoader } from "react-spinners";
|
||||
@@ -7,18 +6,12 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { authenticate } from "../../api/user";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
HOST,
|
||||
} from "../../constants";
|
||||
import { DEMO_USER } from "../../constants";
|
||||
import { CardType } from "../../models/CardType";
|
||||
import { STAGE_LABELS } from "../../models/Stage";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
import { useTheme } from "../../redux/selectors/themeSelectors";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
usePrivateAccess,
|
||||
@@ -26,12 +19,15 @@ import {
|
||||
} from "../../redux/selectors/userSelectors";
|
||||
import { login } from "../../redux/slices/user";
|
||||
|
||||
import { getFullSuffix } from "./getFullSuffix";
|
||||
import { buildCardUrl } from "./buildCardUrl";
|
||||
import { getDefaultCardOptions } from "./cardOptions";
|
||||
import type { CardOptions } from "./cardOptions";
|
||||
import { CustomizeStage } from "./stages/Customize";
|
||||
import { DisplayStage } from "./stages/Display";
|
||||
import { LoginStage } from "./stages/Login/Login";
|
||||
import { SelectCardStage } from "./stages/SelectCard";
|
||||
import { ThemeStage } from "./stages/Theme";
|
||||
import { useCardDescriptor } from "./useCardDescriptor";
|
||||
|
||||
interface HomeScreenProps {
|
||||
stage: StageIndex;
|
||||
@@ -47,51 +43,44 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// for stage two
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId);
|
||||
const [repo, setRepo] = useState(DEMO_REPO);
|
||||
const [gist, setGist] = useState(DEMO_GIST);
|
||||
const [wakatimeUser, setWakatimeUser] = useState(DEMO_WAKATIME_USER);
|
||||
|
||||
const [selectedCard, setSelectedCard] = useState<CardType>(CardType.STATS);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedUserId(userId);
|
||||
}, [userId]);
|
||||
|
||||
// for stage three
|
||||
const [selectedStatsRank, setSelectedStatsRank] =
|
||||
useState(STATS_DEFAULT_RANK);
|
||||
const [selectedLanguagesLayout, setSelectedLanguagesLayout] = useState(
|
||||
LANGUAGES_DEFAULT_LAYOUT,
|
||||
);
|
||||
const [selectedWakatimeLayout, setSelectedWakatimeLayout] = useState(
|
||||
WAKATIME_DEFAULT_LAYOUT,
|
||||
// for stages two and three
|
||||
const [cardOptions, setCardOptions] = useState(() =>
|
||||
getDefaultCardOptions(userId),
|
||||
);
|
||||
|
||||
const [showTitle, setShowTitle] = useState(true);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [descriptionLines, setDescriptionLines] = useState<
|
||||
number | undefined
|
||||
>();
|
||||
const [customTitle, setCustomTitle] = useState("");
|
||||
const [langsCount, setLangsCount] = useState<number | undefined>();
|
||||
const [hideValues, setHideValues] = useState(false);
|
||||
const [showAllStats, setShowAllStats] = useState(false);
|
||||
const [showIcons, setShowIcons] = useState(false);
|
||||
const [includeAllCommits, setIncludeAllCommits] = useState(true);
|
||||
const [enableAnimations, setEnableAnimations] = useState(true);
|
||||
const [usePercent, setUsePercent] = useState(false);
|
||||
const setCardOption = useCallback<
|
||||
<K extends keyof CardOptions>(key: K, value: CardOptions[K]) => void
|
||||
>((key, value) => {
|
||||
setCardOptions((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const [theme, setTheme] = useState("default");
|
||||
// Reset the selected user to the resolved account id when it changes,
|
||||
// adjusting state during render rather than in an effect:
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
const [prevUserId, setPrevUserId] = useState(userId);
|
||||
if (userId !== prevUserId) {
|
||||
setPrevUserId(userId);
|
||||
setCardOptions((prev) => ({ ...prev, selectedUserId: userId }));
|
||||
}
|
||||
|
||||
const { isDark } = useTheme();
|
||||
const [theme, setTheme] = useState(isDark ? "dark" : "default");
|
||||
|
||||
const handleCardTypeChange = (cardType: CardType) => {
|
||||
if (cardType === CardType.TOP_LANGS) {
|
||||
setLangsCount(4);
|
||||
setSelectedWakatimeLayout(WAKATIME_DEFAULT_LAYOUT);
|
||||
setCardOptions((prev) => ({
|
||||
...prev,
|
||||
langsCount: 4,
|
||||
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
|
||||
}));
|
||||
} else if (cardType === CardType.WAKATIME) {
|
||||
setLangsCount(6);
|
||||
setSelectedLanguagesLayout(LANGUAGES_DEFAULT_LAYOUT);
|
||||
setCardOptions((prev) => ({
|
||||
...prev,
|
||||
langsCount: 6,
|
||||
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
|
||||
}));
|
||||
}
|
||||
|
||||
if (theme === "default" || theme === "default_repocard") {
|
||||
@@ -107,77 +96,41 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
setStage(2);
|
||||
};
|
||||
|
||||
const fullSuffix = getFullSuffix({
|
||||
userId,
|
||||
selectedCard,
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
});
|
||||
// Memoized so the builder keeps a stable reference across renders when its
|
||||
// inputs are unchanged. Card components consume it by value (they derive a URL
|
||||
// string), but memoizing keeps it safe to pass as a prop if any of them are
|
||||
// ever wrapped in React.memo.
|
||||
const cardBuilder = useMemo(
|
||||
() => buildCardUrl(userId, selectedCard, cardOptions),
|
||||
[userId, selectedCard, cardOptions],
|
||||
);
|
||||
|
||||
// Preview builder for the customize stage, dark-themed to match the surroundings.
|
||||
const customizeCardBuilder = useMemo(
|
||||
() => (isDark ? cardBuilder.theme("github_dark") : cardBuilder),
|
||||
[cardBuilder, isDark],
|
||||
);
|
||||
|
||||
// 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 (
|
||||
!(
|
||||
(theme === "default" &&
|
||||
[CardType.STATS, CardType.TOP_LANGS, CardType.WAKATIME].includes(
|
||||
selectedCard as never,
|
||||
)) ||
|
||||
(theme === "default_repocard" &&
|
||||
[CardType.PIN, CardType.GIST].includes(selectedCard as never))
|
||||
)
|
||||
) {
|
||||
themeSuffix += `&theme=${theme}`;
|
||||
}
|
||||
const themeBuilder = useMemo(
|
||||
() => (isDefaultTheme ? cardBuilder : cardBuilder.theme(theme)),
|
||||
[cardBuilder, isDefaultTheme, theme],
|
||||
);
|
||||
|
||||
// for stage five
|
||||
const [gistUrl, setGistUrl] = useState("");
|
||||
|
||||
const guestHint = useMemo(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `username "${DEMO_USER}"`;
|
||||
case CardType.PIN:
|
||||
return `repo "${DEMO_REPO}"`;
|
||||
case CardType.GIST:
|
||||
return `Gist ID "${DEMO_GIST}"`;
|
||||
case CardType.WAKATIME:
|
||||
return `WakaTime username "${DEMO_WAKATIME_USER}"`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
}, [selectedCard]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchGistURL() {
|
||||
try {
|
||||
const fullUrl = `https://api.github.com/gists/${gist}`;
|
||||
const result = await axios.get<{ html_url: string }>(fullUrl);
|
||||
setGistUrl(result.data.html_url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
void fetchGistURL();
|
||||
}, [gist]);
|
||||
const cardDescriptor = useCardDescriptor({
|
||||
selectedCard,
|
||||
themeBuilder,
|
||||
repo: cardOptions.repo,
|
||||
userId,
|
||||
wakatimeUser: cardOptions.wakatimeUser,
|
||||
gist: cardOptions.gist,
|
||||
});
|
||||
|
||||
const contentSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -194,31 +147,31 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
}, [stage]);
|
||||
|
||||
useEffect(() => {
|
||||
async function redirectCode() {
|
||||
// After requesting GitHub access, GitHub redirects back to your app with a code parameter
|
||||
const url = window.location.href;
|
||||
// After requesting GitHub access, GitHub redirects back to the app with a
|
||||
// code parameter appended to the redirect URI (which carries mode=private|public).
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get("code");
|
||||
if (code === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If GitHub API returns the code parameter
|
||||
if (url.includes("code=")) {
|
||||
const tempPrivateAccess = url.includes("private");
|
||||
const newUrl = url.split("code=", 2) as [string, string];
|
||||
const redirect = `${url.split(HOST)[0] as string}${HOST}/frontend`;
|
||||
window.history.pushState({}, "", redirect);
|
||||
setIsLoading(true);
|
||||
const tempPrivateAccess = url.searchParams.get("mode") === "private";
|
||||
window.history.pushState({}, "", `${url.origin}/frontend`);
|
||||
|
||||
async function exchangeCode(code: string) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const userKey = uuidv4();
|
||||
const newUserId = await authenticate(
|
||||
newUrl[1],
|
||||
tempPrivateAccess,
|
||||
userKey,
|
||||
);
|
||||
|
||||
const newUserId = await authenticate(code, tempPrivateAccess, userKey);
|
||||
dispatch(login({ userId: newUserId, userKey }));
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void redirectCode();
|
||||
void exchangeCode(code);
|
||||
}, [dispatch]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -230,11 +183,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={contentSectionRef} className="h-full px-2 lg:px-8 text-gray-600">
|
||||
<div
|
||||
ref={contentSectionRef}
|
||||
className="h-full px-2 lg:px-8 text-base-content/70"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="m-4 rounded-sm">
|
||||
<div className="lg:p-4">
|
||||
<h1 className="text-2xl text-gray-600 font-semibold">
|
||||
<h1 className="text-2xl text-base-content/70 font-semibold">
|
||||
{STAGE_LABELS[stage].title}
|
||||
</h1>
|
||||
<div>
|
||||
@@ -245,7 +201,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
<a
|
||||
href={`https://github.com/${userId}`}
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:underline font-semibold"
|
||||
className="text-primary hover:underline font-semibold"
|
||||
>
|
||||
{userId}
|
||||
</a>
|
||||
@@ -266,13 +222,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
[
|
||||
"",
|
||||
"You will be able to customize your card in future steps.",
|
||||
"",
|
||||
"",
|
||||
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
|
||||
][stage]
|
||||
STAGE_LABELS[stage].description
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,49 +242,15 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
{stage === 2 && (
|
||||
<CustomizeStage
|
||||
selectedCard={selectedCard}
|
||||
selectedStatsRank={selectedStatsRank}
|
||||
setSelectedStatsRank={setSelectedStatsRank}
|
||||
selectedLanguagesLayout={selectedLanguagesLayout}
|
||||
setSelectedLanguagesLayout={setSelectedLanguagesLayout}
|
||||
selectedWakatimeLayout={selectedWakatimeLayout}
|
||||
setSelectedWakatimeLayout={setSelectedWakatimeLayout}
|
||||
showTitle={showTitle}
|
||||
setShowTitle={setShowTitle}
|
||||
showOwner={showOwner}
|
||||
setShowOwner={setShowOwner}
|
||||
descriptionLines={descriptionLines}
|
||||
setDescriptionLines={setDescriptionLines}
|
||||
customTitle={customTitle}
|
||||
setCustomTitle={setCustomTitle}
|
||||
langsCount={langsCount}
|
||||
setLangsCount={setLangsCount}
|
||||
hideValues={hideValues}
|
||||
setHideValues={setHideValues}
|
||||
showAllStats={showAllStats}
|
||||
setShowAllStats={setShowAllStats}
|
||||
showIcons={showIcons}
|
||||
setShowIcons={setShowIcons}
|
||||
includeAllCommits={includeAllCommits}
|
||||
setIncludeAllCommits={setIncludeAllCommits}
|
||||
enableAnimations={enableAnimations}
|
||||
setEnableAnimations={setEnableAnimations}
|
||||
selectedUserId={selectedUserId}
|
||||
setSelectedUserId={setSelectedUserId}
|
||||
repo={repo}
|
||||
setRepo={setRepo}
|
||||
gist={gist}
|
||||
setGist={setGist}
|
||||
wakatimeUser={wakatimeUser}
|
||||
setWakatimeUser={setWakatimeUser}
|
||||
usePercent={usePercent}
|
||||
setUsePercent={setUsePercent}
|
||||
fullSuffix={fullSuffix}
|
||||
options={cardOptions}
|
||||
onOptionChange={setCardOption}
|
||||
card={customizeCardBuilder}
|
||||
setStage={setStage}
|
||||
/>
|
||||
)}
|
||||
{stage === 3 && (
|
||||
<ThemeStage
|
||||
fullSuffix={fullSuffix}
|
||||
card={cardBuilder}
|
||||
theme={theme}
|
||||
onThemeChange={(theme) => {
|
||||
setTheme(theme);
|
||||
@@ -344,49 +260,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
)}
|
||||
{stage === 4 && (
|
||||
<DisplayStage
|
||||
filename={(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `${selectedUserId}_card`;
|
||||
case CardType.PIN:
|
||||
return `${repo}_card`;
|
||||
case CardType.GIST:
|
||||
return `gist_card`;
|
||||
case CardType.WAKATIME:
|
||||
return `${wakatimeUser}_card`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
link={(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `https://${HOST}/api${themeSuffix}`;
|
||||
|
||||
case CardType.PIN: {
|
||||
let myRepo = repo;
|
||||
if (!myRepo.includes("/")) {
|
||||
myRepo = `${userId}/${myRepo}`;
|
||||
}
|
||||
return `https://github.com/${myRepo}`;
|
||||
}
|
||||
case CardType.GIST:
|
||||
return gistUrl;
|
||||
case CardType.WAKATIME:
|
||||
return `https://wakatime.com/@${wakatimeUser}`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
themeSuffix={themeSuffix}
|
||||
filename={cardBuilder.filename()}
|
||||
link={cardDescriptor.link}
|
||||
theme={theme}
|
||||
card={themeBuilder}
|
||||
guestHint={
|
||||
isAuthenticated
|
||||
? null
|
||||
: `Replace the sample ${guestHint} with your own after copying your Markdown or URL!`
|
||||
: `Replace the sample ${cardDescriptor.guestHint} with your own after copying your Markdown or URL!`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
+24
-25
@@ -5,12 +5,15 @@ import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/Stat
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
|
||||
import { getFullSuffix } from "./getFullSuffix";
|
||||
import { buildCardUrl } from "./buildCardUrl";
|
||||
import type { CardOptions } from "./cardOptions";
|
||||
|
||||
const baseOptions = {
|
||||
selectedCard: CardType.STATS,
|
||||
const USER_ID = "john-github";
|
||||
|
||||
// Built inline instead of via `getDefaultCardOptions`, which imports
|
||||
// `constants.ts` and needs a `window` global these node-based tests lack.
|
||||
const baseOptions: CardOptions = {
|
||||
selectedUserId: "john",
|
||||
userId: "john-github",
|
||||
repo: "repo1",
|
||||
gist: "gist1",
|
||||
wakatimeUser: "wakaUser",
|
||||
@@ -30,15 +33,15 @@ const baseOptions = {
|
||||
usePercent: false,
|
||||
};
|
||||
|
||||
describe("getFullSuffix", () => {
|
||||
describe("buildCardUrl", () => {
|
||||
it("builds stats suffix with defaults", () => {
|
||||
const result = getFullSuffix(baseOptions);
|
||||
const result = buildCardUrl(USER_ID, CardType.STATS, baseOptions);
|
||||
|
||||
expect(result).toBe("?username=john");
|
||||
expect(result.toString()).toBe("?username=john");
|
||||
});
|
||||
|
||||
it("adds stats options", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.STATS, {
|
||||
...baseOptions,
|
||||
showIcons: true,
|
||||
includeAllCommits: true,
|
||||
@@ -46,73 +49,69 @@ describe("getFullSuffix", () => {
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"?username=john" +
|
||||
"&hide_title=true" +
|
||||
"&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented" +
|
||||
// commas are percent-encoded (%2C) now that params go through URLSearchParams
|
||||
"&show=reviews%2Cdiscussions_started%2Cdiscussions_answered%2Cprs_merged%2Cprs_merged_percentage%2Cprs_commented%2Cprs_reviewed%2Cissues_commented" +
|
||||
"&show_icons=true" +
|
||||
"&include_all_commits=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds top-langs suffix", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.TOP_LANGS,
|
||||
langsCount: 5,
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"/top-langs?username=john&hide_title=true&langs_count=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds pin suffix using userId not selectedUserId", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.PIN, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.PIN,
|
||||
showOwner: true,
|
||||
descriptionLines: 3,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds gist suffix", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.GIST, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.GIST,
|
||||
showOwner: true,
|
||||
});
|
||||
|
||||
expect(result).toBe("/gist?id=gist1&show_owner=true");
|
||||
expect(result.toString()).toBe("/gist?id=gist1&show_owner=true");
|
||||
});
|
||||
|
||||
it("builds wakatime suffix with percent and custom title", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.WAKATIME, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.WAKATIME,
|
||||
wakatimeUser: "waka",
|
||||
usePercent: true,
|
||||
customTitle: "My Stats",
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
expect(result.toString()).toBe(
|
||||
"/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds non-default layouts", () => {
|
||||
const result = getFullSuffix({
|
||||
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
|
||||
...baseOptions,
|
||||
selectedCard: CardType.TOP_LANGS,
|
||||
selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" },
|
||||
});
|
||||
|
||||
expect(result).toBe("/top-langs?username=john&layout=compact");
|
||||
expect(result.toString()).toBe("/top-langs?username=john&layout=compact");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
import { cardUrl } from "../../models/CardUrl";
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
import type { CardOptions } from "./cardOptions";
|
||||
|
||||
export function buildCardUrl(
|
||||
userId: string,
|
||||
selectedCard: CardType,
|
||||
options: CardOptions,
|
||||
): CardUrlBuilder {
|
||||
const {
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
} = options;
|
||||
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS: {
|
||||
let url = cardUrl(CardType.STATS);
|
||||
if (selectedUserId) {
|
||||
url = url.username(selectedUserId);
|
||||
}
|
||||
if (selectedStatsRank !== STATS_DEFAULT_RANK) {
|
||||
if (selectedStatsRank.value === "hide_rank") {
|
||||
url = url.hideRank();
|
||||
} else {
|
||||
url = url.rankIcon(selectedStatsRank.value);
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
url = url.hideTitle();
|
||||
}
|
||||
if (customTitle) {
|
||||
url = url.customTitle(customTitle);
|
||||
}
|
||||
if (showAllStats) {
|
||||
url = url.show(
|
||||
"reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented",
|
||||
);
|
||||
}
|
||||
if (showIcons) {
|
||||
url = url.showIcons();
|
||||
}
|
||||
if (includeAllCommits) {
|
||||
url = url.includeAllCommits();
|
||||
}
|
||||
if (!enableAnimations) {
|
||||
url = url.disableAnimations();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.TOP_LANGS: {
|
||||
let url = cardUrl(CardType.TOP_LANGS);
|
||||
if (selectedUserId) {
|
||||
url = url.username(selectedUserId);
|
||||
}
|
||||
if (selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT) {
|
||||
if (selectedLanguagesLayout.value === "hide_progress") {
|
||||
url = url.hideProgress();
|
||||
} else {
|
||||
url = url.layout(selectedLanguagesLayout.value);
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
url = url.hideTitle();
|
||||
}
|
||||
if (langsCount) {
|
||||
url = url.langsCount(langsCount);
|
||||
}
|
||||
if (hideValues) {
|
||||
url = url.hideValues();
|
||||
}
|
||||
if (!enableAnimations) {
|
||||
url = url.disableAnimations();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.PIN: {
|
||||
/**
|
||||
* We should use the name of the logged-in user, not the value entered in the
|
||||
* username field in step 3.
|
||||
*
|
||||
* This input is not shown when the PIN card type is selected,
|
||||
* but it may still contain a different value if the user previously chose another card type.
|
||||
*
|
||||
* For example,
|
||||
* 1. the user could select the STATS card
|
||||
* 2. enter a username
|
||||
* 3. then go back and switch to the PIN card, leaving the old value behind.
|
||||
*
|
||||
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
|
||||
*/
|
||||
let url = cardUrl(CardType.PIN);
|
||||
if (userId) {
|
||||
url = url.username(userId);
|
||||
}
|
||||
if (repo) {
|
||||
url = url.repo(repo);
|
||||
}
|
||||
if (showOwner) {
|
||||
url = url.showOwner();
|
||||
}
|
||||
if (descriptionLines) {
|
||||
url = url.descriptionLines(descriptionLines);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.GIST: {
|
||||
let url = cardUrl(CardType.GIST);
|
||||
if (gist) {
|
||||
url = url.gistId(gist);
|
||||
}
|
||||
if (showOwner) {
|
||||
url = url.showOwner();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case CardType.WAKATIME: {
|
||||
let url = cardUrl(CardType.WAKATIME);
|
||||
if (wakatimeUser) {
|
||||
url = url.username(wakatimeUser);
|
||||
}
|
||||
if (selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT) {
|
||||
if (selectedWakatimeLayout.value === "hide_progress") {
|
||||
url = url.hideProgress().cardWidth(315);
|
||||
} else {
|
||||
url = url.layout(selectedWakatimeLayout.value);
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
url = url.hideTitle();
|
||||
}
|
||||
if (customTitle) {
|
||||
url = url.customTitle(customTitle);
|
||||
}
|
||||
if (langsCount) {
|
||||
url = url.langsCount(langsCount);
|
||||
}
|
||||
if (!enableAnimations) {
|
||||
url = url.disableAnimations();
|
||||
}
|
||||
if (usePercent) {
|
||||
url = url.displayFormat("percent");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
throw new Error(`unknown card type: ${selectedCard as string}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SelectOption } from "../../components/Generic/Select";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { DEMO_GIST, DEMO_REPO, DEMO_WAKATIME_USER } from "../../constants";
|
||||
|
||||
/**
|
||||
* All user-tunable card parameters collected during the customize stage.
|
||||
* Held as a single state object in `HomeScreen` and updated one key at a time.
|
||||
*/
|
||||
export interface CardOptions {
|
||||
selectedUserId: string;
|
||||
repo: string;
|
||||
gist: string;
|
||||
wakatimeUser: string;
|
||||
selectedStatsRank: SelectOption;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
showTitle: boolean;
|
||||
showOwner: boolean;
|
||||
descriptionLines: number | undefined;
|
||||
customTitle: string;
|
||||
langsCount: number | undefined;
|
||||
hideValues: boolean;
|
||||
showAllStats: boolean;
|
||||
showIcons: boolean;
|
||||
includeAllCommits: boolean;
|
||||
enableAnimations: boolean;
|
||||
usePercent: boolean;
|
||||
}
|
||||
|
||||
export function getDefaultCardOptions(userId: string): CardOptions {
|
||||
return {
|
||||
selectedUserId: userId,
|
||||
repo: DEMO_REPO,
|
||||
gist: DEMO_GIST,
|
||||
wakatimeUser: DEMO_WAKATIME_USER,
|
||||
selectedStatsRank: STATS_DEFAULT_RANK,
|
||||
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
|
||||
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
|
||||
showTitle: true,
|
||||
showOwner: false,
|
||||
descriptionLines: undefined,
|
||||
customTitle: "",
|
||||
langsCount: undefined,
|
||||
hideValues: false,
|
||||
showAllStats: false,
|
||||
showIcons: false,
|
||||
includeAllCommits: true,
|
||||
enableAnimations: true,
|
||||
usePercent: false,
|
||||
};
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { SelectOption } from "../../components/Generic/Select";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
|
||||
interface Options {
|
||||
userId: string;
|
||||
selectedUserId: string;
|
||||
selectedCard: CardType;
|
||||
repo: string;
|
||||
gist: string;
|
||||
wakatimeUser: string;
|
||||
selectedStatsRank: SelectOption;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
showTitle: boolean;
|
||||
showOwner: boolean;
|
||||
descriptionLines: number | undefined;
|
||||
customTitle: string;
|
||||
langsCount: number | undefined;
|
||||
hideValues: boolean;
|
||||
showAllStats: boolean;
|
||||
showIcons: boolean;
|
||||
includeAllCommits: boolean;
|
||||
enableAnimations: boolean;
|
||||
usePercent: boolean;
|
||||
}
|
||||
|
||||
export function getFullSuffix({
|
||||
userId,
|
||||
selectedCard,
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
}: Options): string {
|
||||
let fullSuffix = `${selectedCard === CardType.STATS ? "" : "/" + selectedCard}?`;
|
||||
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
fullSuffix += `username=${selectedUserId}`;
|
||||
break;
|
||||
case CardType.PIN:
|
||||
/**
|
||||
* We should use the name of the logged-in user, not the value entered in the
|
||||
* username field in step 3.
|
||||
*
|
||||
* This input is not shown when the PIN card type is selected,
|
||||
* but it may still contain a different value if the user previously chose another card type.
|
||||
*
|
||||
* For example,
|
||||
* 1. the user could select the STATS card
|
||||
* 2. enter a username
|
||||
* 3. then go back and switch to the PIN card, leaving the old value behind.
|
||||
*
|
||||
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
|
||||
*/
|
||||
fullSuffix += `username=${userId}&repo=${repo}`;
|
||||
break;
|
||||
case CardType.GIST:
|
||||
fullSuffix += `id=${gist}`;
|
||||
break;
|
||||
case CardType.WAKATIME:
|
||||
fullSuffix += `username=${wakatimeUser}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedStatsRank !== STATS_DEFAULT_RANK &&
|
||||
selectedCard === CardType.STATS
|
||||
) {
|
||||
fullSuffix += `&rank_icon=${selectedStatsRank.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT &&
|
||||
selectedCard === CardType.TOP_LANGS
|
||||
) {
|
||||
fullSuffix += `&layout=${selectedLanguagesLayout.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT &&
|
||||
selectedCard === CardType.WAKATIME
|
||||
) {
|
||||
fullSuffix += `&layout=${selectedWakatimeLayout.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!showTitle &&
|
||||
(selectedCard === CardType.STATS ||
|
||||
selectedCard === CardType.TOP_LANGS ||
|
||||
selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += "&hide_title=true";
|
||||
}
|
||||
|
||||
if (
|
||||
showOwner &&
|
||||
(selectedCard === CardType.PIN || selectedCard === CardType.GIST)
|
||||
) {
|
||||
fullSuffix += "&show_owner=true";
|
||||
}
|
||||
|
||||
if (descriptionLines && selectedCard === CardType.PIN) {
|
||||
fullSuffix += `&description_lines_count=${descriptionLines}`;
|
||||
}
|
||||
|
||||
if (
|
||||
customTitle &&
|
||||
(selectedCard === CardType.STATS || selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
const encodedTitle = encodeURIComponent(customTitle);
|
||||
fullSuffix += `&custom_title=${encodedTitle}`;
|
||||
}
|
||||
|
||||
if (
|
||||
langsCount &&
|
||||
(selectedCard === CardType.TOP_LANGS || selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += `&langs_count=${langsCount}`;
|
||||
}
|
||||
|
||||
if (hideValues && selectedCard === CardType.TOP_LANGS) {
|
||||
fullSuffix += `&hide_values=true`;
|
||||
}
|
||||
|
||||
if (showAllStats && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented`;
|
||||
}
|
||||
|
||||
if (showIcons && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show_icons=true`;
|
||||
}
|
||||
|
||||
if (includeAllCommits && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&include_all_commits=true`;
|
||||
}
|
||||
|
||||
if (
|
||||
!enableAnimations &&
|
||||
(selectedCard === CardType.STATS ||
|
||||
selectedCard === CardType.TOP_LANGS ||
|
||||
selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += `&disable_animations=${!enableAnimations}`;
|
||||
}
|
||||
|
||||
if (usePercent && selectedCard === CardType.WAKATIME) {
|
||||
fullSuffix += `&display_format=percent`;
|
||||
}
|
||||
|
||||
return fullSuffix;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { JSX, default as React } from "react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { CardImage } from "../../../components/Card/CardImage";
|
||||
import type { SelectOption } from "../../../components/Generic/Select";
|
||||
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
|
||||
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
|
||||
import { NumericSection } from "../../../components/Home/NumericSection";
|
||||
@@ -15,101 +14,72 @@ import {
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../constants";
|
||||
import { CardType } from "../../../models/CardType";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import type { StageIndex } from "../../../models/Stage";
|
||||
import { useIsAuthenticated } from "../../../redux/selectors/userSelectors";
|
||||
import type { CardOptions } from "../cardOptions";
|
||||
|
||||
type Updater<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
/**
|
||||
* Extract the trailing path segments from pasted text, so pasting a full
|
||||
* GitHub URL yields just the username (1 segment), owner/repo (2), or Gist id (1).
|
||||
*/
|
||||
function pastedPathTail(text: string, segments: number): string {
|
||||
let value = text;
|
||||
if (value.endsWith("/")) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
const parts = value.split("/");
|
||||
if (parts.length > segments) {
|
||||
value = parts.slice(-segments).join("/");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @todo todo consider using React context API to avoid prop drilling */
|
||||
interface CustomizeStageProps {
|
||||
selectedCard: CardType;
|
||||
selectedStatsRank: SelectOption;
|
||||
setSelectedStatsRank: Updater<SelectOption>;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
setSelectedLanguagesLayout: Updater<SelectOption>;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
setSelectedWakatimeLayout: Updater<SelectOption>;
|
||||
selectedUserId: string;
|
||||
setSelectedUserId: Updater<string>;
|
||||
repo: string;
|
||||
setRepo: Updater<string>;
|
||||
gist: string;
|
||||
setGist: Updater<string>;
|
||||
wakatimeUser: string;
|
||||
setWakatimeUser: Updater<string>;
|
||||
showTitle: boolean;
|
||||
setShowTitle: Updater<boolean>;
|
||||
descriptionLines: number | undefined;
|
||||
setDescriptionLines: Updater<number | undefined>;
|
||||
showOwner: boolean;
|
||||
setShowOwner: Updater<boolean>;
|
||||
customTitle: string;
|
||||
setCustomTitle: Updater<string>;
|
||||
langsCount: number | undefined;
|
||||
setLangsCount: Updater<number | undefined>;
|
||||
hideValues: boolean;
|
||||
setHideValues: Updater<boolean>;
|
||||
showIcons: boolean;
|
||||
setShowIcons: Updater<boolean>;
|
||||
showAllStats: boolean;
|
||||
setShowAllStats: Updater<boolean>;
|
||||
includeAllCommits: boolean;
|
||||
setIncludeAllCommits: Updater<boolean>;
|
||||
enableAnimations: boolean;
|
||||
setEnableAnimations: Updater<boolean>;
|
||||
usePercent: boolean;
|
||||
setUsePercent: Updater<boolean>;
|
||||
fullSuffix: string;
|
||||
options: CardOptions;
|
||||
onOptionChange: <K extends keyof CardOptions>(
|
||||
key: K,
|
||||
value: CardOptions[K],
|
||||
) => void;
|
||||
card: CardUrlBuilder;
|
||||
setStage: (stageIndex: StageIndex) => void;
|
||||
}
|
||||
|
||||
export function CustomizeStage({
|
||||
selectedCard,
|
||||
selectedStatsRank,
|
||||
setSelectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
setSelectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
setSelectedWakatimeLayout,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
repo,
|
||||
setRepo,
|
||||
gist,
|
||||
setGist,
|
||||
wakatimeUser,
|
||||
setWakatimeUser,
|
||||
showTitle,
|
||||
setShowTitle,
|
||||
showOwner,
|
||||
setShowOwner,
|
||||
descriptionLines,
|
||||
setDescriptionLines,
|
||||
customTitle,
|
||||
setCustomTitle,
|
||||
langsCount,
|
||||
setLangsCount,
|
||||
hideValues,
|
||||
setHideValues,
|
||||
showIcons,
|
||||
setShowIcons,
|
||||
showAllStats,
|
||||
setShowAllStats,
|
||||
includeAllCommits,
|
||||
setIncludeAllCommits,
|
||||
enableAnimations,
|
||||
setEnableAnimations,
|
||||
usePercent,
|
||||
setUsePercent,
|
||||
fullSuffix,
|
||||
options,
|
||||
onOptionChange,
|
||||
card,
|
||||
setStage,
|
||||
}: CustomizeStageProps): JSX.Element {
|
||||
const cardType = selectedCard;
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const {
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
} = options;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2 p-10 rounded-sm bg-gray-200">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2 p-10 rounded-sm bg-base-200">
|
||||
{(cardType === CardType.STATS || cardType === CardType.TOP_LANGS) && (
|
||||
<TextSection
|
||||
title="Username"
|
||||
@@ -126,7 +96,7 @@ export function CustomizeStage({
|
||||
e.preventDefault();
|
||||
setStage(0);
|
||||
}}
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
log in
|
||||
</a>{" "}
|
||||
@@ -137,19 +107,16 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_USER}"`}
|
||||
value={selectedUserId}
|
||||
onValueChange={setSelectedUserId}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("selectedUserId", value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
let newValue = e.clipboardData.getData("text");
|
||||
// if the user pasted a full GitHub URL, extract username
|
||||
if (newValue.endsWith("/")) {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
const parts = newValue.split("/");
|
||||
if (parts.length > 1) {
|
||||
newValue = parts.slice(-1).join("/");
|
||||
}
|
||||
setSelectedUserId(newValue);
|
||||
onOptionChange(
|
||||
"selectedUserId",
|
||||
pastedPathTail(e.clipboardData.getData("text"), 1),
|
||||
);
|
||||
}}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
@@ -170,7 +137,7 @@ export function CustomizeStage({
|
||||
e.preventDefault();
|
||||
setStage(0);
|
||||
}}
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
log in
|
||||
</a>{" "}
|
||||
@@ -181,19 +148,16 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_REPO}"`}
|
||||
value={repo}
|
||||
onValueChange={setRepo}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("repo", value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
let newValue = e.clipboardData.getData("text");
|
||||
// if the user pasted a full GitHub URL, extract owner/repo
|
||||
if (newValue.endsWith("/")) {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
const parts = newValue.split("/");
|
||||
if (parts.length > 2) {
|
||||
newValue = parts.slice(-2).join("/");
|
||||
}
|
||||
setRepo(newValue);
|
||||
onOptionChange(
|
||||
"repo",
|
||||
pastedPathTail(e.clipboardData.getData("text"), 2),
|
||||
);
|
||||
}}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
@@ -214,7 +178,7 @@ export function CustomizeStage({
|
||||
e.preventDefault();
|
||||
setStage(0);
|
||||
}}
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
log in
|
||||
</a>{" "}
|
||||
@@ -225,19 +189,16 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_GIST}"`}
|
||||
value={gist}
|
||||
onValueChange={setGist}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("gist", value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
let newValue = e.clipboardData.getData("text");
|
||||
// if the user pasted a full GitHub URL, extract Gist ID
|
||||
if (newValue.endsWith("/")) {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
const parts = newValue.split("/");
|
||||
if (parts.length > 1) {
|
||||
newValue = parts.slice(-1).join("/");
|
||||
}
|
||||
setGist(newValue);
|
||||
onOptionChange(
|
||||
"gist",
|
||||
pastedPathTail(e.clipboardData.getData("text"), 1),
|
||||
);
|
||||
}}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
@@ -251,7 +212,7 @@ export function CustomizeStage({
|
||||
<a
|
||||
href="https://wakatime.com/"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
WakaTime
|
||||
</a>{" "}
|
||||
@@ -260,7 +221,9 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
|
||||
value={wakatimeUser}
|
||||
onValueChange={setWakatimeUser}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("wakatimeUser", value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
@@ -269,13 +232,17 @@ export function CustomizeStage({
|
||||
text="Show all available statistics."
|
||||
question="Show all stats?"
|
||||
checked={showAllStats}
|
||||
onCheckedChange={setShowAllStats}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showAllStats", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
<StatsRankSection
|
||||
selectedOption={selectedStatsRank}
|
||||
onOptionChange={setSelectedStatsRank}
|
||||
onOptionChange={(option) => {
|
||||
onOptionChange("selectedStatsRank", option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
@@ -284,7 +251,9 @@ export function CustomizeStage({
|
||||
text="Show icons next to all stats."
|
||||
question="Show icons?"
|
||||
checked={showIcons}
|
||||
onCheckedChange={setShowIcons}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showIcons", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.STATS && (
|
||||
@@ -293,19 +262,25 @@ export function CustomizeStage({
|
||||
text="Count total commits or just commits of the last 365 days."
|
||||
question="Include all commits?"
|
||||
checked={includeAllCommits}
|
||||
onCheckedChange={setIncludeAllCommits}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("includeAllCommits", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.TOP_LANGS && (
|
||||
<LanguagesLayoutSection
|
||||
selectedLanguageLayoutOption={selectedLanguagesLayout}
|
||||
onLanguageLayoutOptionChange={setSelectedLanguagesLayout}
|
||||
onLanguageLayoutOptionChange={(option) => {
|
||||
onOptionChange("selectedLanguagesLayout", option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.WAKATIME && (
|
||||
<WakatimeLayoutSection
|
||||
selectedOption={selectedWakatimeLayout}
|
||||
onOptionChange={setSelectedWakatimeLayout}
|
||||
onOptionChange={(option) => {
|
||||
onOptionChange("selectedWakatimeLayout", option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.TOP_LANGS ||
|
||||
@@ -320,7 +295,9 @@ export function CustomizeStage({
|
||||
</>
|
||||
}
|
||||
value={langsCount}
|
||||
onValueChange={setLangsCount}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("langsCount", value);
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
/>
|
||||
@@ -331,7 +308,9 @@ export function CustomizeStage({
|
||||
text="Hide language percentages or bytes while keeping the selected layout visible."
|
||||
question="Hide values?"
|
||||
checked={hideValues}
|
||||
onCheckedChange={setHideValues}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("hideValues", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.WAKATIME && (
|
||||
@@ -340,7 +319,9 @@ export function CustomizeStage({
|
||||
text="Show time spent in hours or percentages."
|
||||
question="Show percentages?"
|
||||
checked={usePercent}
|
||||
onCheckedChange={setUsePercent}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("usePercent", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.STATS ||
|
||||
@@ -351,7 +332,9 @@ export function CustomizeStage({
|
||||
text="Shows a title at the top of the card."
|
||||
question="Show title?"
|
||||
checked={showTitle}
|
||||
onCheckedChange={setShowTitle}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showTitle", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
|
||||
@@ -366,7 +349,9 @@ export function CustomizeStage({
|
||||
}
|
||||
placeholder='e.g. "My GitHub Stats"'
|
||||
value={customTitle}
|
||||
onValueChange={setCustomTitle}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("customTitle", value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(cardType === CardType.STATS ||
|
||||
@@ -377,7 +362,9 @@ export function CustomizeStage({
|
||||
// text="Enable Animations."
|
||||
question="enable animations?"
|
||||
checked={enableAnimations}
|
||||
onCheckedChange={setEnableAnimations}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("enableAnimations", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(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."
|
||||
question="Show owner?"
|
||||
checked={showOwner}
|
||||
onCheckedChange={setShowOwner}
|
||||
onCheckedChange={(checked) => {
|
||||
onOptionChange("showOwner", checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.PIN && (
|
||||
@@ -401,7 +390,9 @@ export function CustomizeStage({
|
||||
</>
|
||||
}
|
||||
value={descriptionLines}
|
||||
onValueChange={setDescriptionLines}
|
||||
onValueChange={(value) => {
|
||||
onOptionChange("descriptionLines", value);
|
||||
}}
|
||||
min={1}
|
||||
max={3}
|
||||
/>
|
||||
@@ -411,7 +402,7 @@ export function CustomizeStage({
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
customization documentation
|
||||
</a>{" "}
|
||||
@@ -420,7 +411,7 @@ export function CustomizeStage({
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
|
||||
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
|
||||
<CardImage imageSrc={fullSuffix} stage={2} />
|
||||
<CardImage card={card} stage={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { saveSvgAsPng } from "save-svg-as-png";
|
||||
|
||||
import { CardImage } from "../../../components/Card/CardImage";
|
||||
import { getCardThemeBackdrop } from "../../../components/Card/themeBackdrop";
|
||||
import { Button } from "../../../components/Generic/Button";
|
||||
import { HOST } from "../../../constants";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||
|
||||
interface DisplayStageProps {
|
||||
filename: string;
|
||||
link: string;
|
||||
themeSuffix: string;
|
||||
theme: string;
|
||||
card: CardUrlBuilder;
|
||||
guestHint: string | null;
|
||||
}
|
||||
|
||||
export function DisplayStage({
|
||||
filename,
|
||||
link,
|
||||
themeSuffix,
|
||||
theme,
|
||||
card,
|
||||
guestHint,
|
||||
}: DisplayStageProps): JSX.Element {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const downloadPNG = () => {
|
||||
saveSvgAsPng(
|
||||
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
|
||||
@@ -34,7 +40,7 @@ export function DisplayStage({
|
||||
|
||||
const copyMarkdown = () => {
|
||||
void navigator.clipboard.writeText(
|
||||
`[](${link})`,
|
||||
`[})](${link})`,
|
||||
);
|
||||
toast.info("Copied to Clipboard!", {
|
||||
position: "bottom-right",
|
||||
@@ -47,7 +53,7 @@ export function DisplayStage({
|
||||
};
|
||||
|
||||
const copyUrl = () => {
|
||||
void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
|
||||
void navigator.clipboard.writeText(card.toApiUrl(HOST));
|
||||
toast.info("Copied to Clipboard!", {
|
||||
position: "bottom-right",
|
||||
autoClose: 1500,
|
||||
@@ -61,7 +67,7 @@ export function DisplayStage({
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2">
|
||||
<div className="p-10 rounded-sm bg-gray-200">
|
||||
<div className="p-10 rounded-sm bg-base-200">
|
||||
<div className="flex flex-col items-center">
|
||||
{[
|
||||
{
|
||||
@@ -82,10 +88,8 @@ export function DisplayStage({
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.title}
|
||||
className={clsx("m-4 w-60 flex justify-center", {
|
||||
"bg-blue-500 hover:bg-blue-600 text-white": item.highlight,
|
||||
"bg-white hover:bg-gray-100 text-black": !item.highlight,
|
||||
})}
|
||||
variant={item.highlight ? "primary" : "soft"}
|
||||
className="m-4 w-60 flex justify-center"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.title}
|
||||
@@ -96,10 +100,14 @@ export function DisplayStage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
|
||||
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
|
||||
<div
|
||||
className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32 rounded p-4"
|
||||
style={{ background: getCardThemeBackdrop(theme, isDark) }}
|
||||
>
|
||||
<CardImage
|
||||
imageSrc={`${themeSuffix}&disable_animations=true`}
|
||||
card={card.disableAnimations()}
|
||||
stage={4}
|
||||
className="flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function LoginAccountDeleteModal(
|
||||
<div className="fixed left-0 top-0 w-full h-full">
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<div
|
||||
className="w-96 p-4 bg-white rounded-sm border-2 border-gray-200 text-gray-700"
|
||||
className="w-96 p-4 bg-base-100 rounded-sm border-2 border-base-300 text-base-content"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<p className="mb-1 text-2xl">Delete Account</p>
|
||||
@@ -57,16 +57,10 @@ export function LoginAccountDeleteModal(
|
||||
</p>
|
||||
<br />
|
||||
<div className="flex flex-wrap">
|
||||
<Button
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Button variant="error" className="ml-auto" onClick={onConfirm}>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -56,14 +56,17 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
<a
|
||||
href={`https://${HOST}/api/downgrade?user_key=${userKey as string}`}
|
||||
>
|
||||
<Button className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100">
|
||||
<Button
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">
|
||||
Downgrade to Public Access
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
Switch to public access if you prefer not to share private
|
||||
contributions.
|
||||
</p>
|
||||
@@ -71,14 +74,17 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[320px] text-white bg-blue-500 hover:bg-blue-600">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">
|
||||
Upgrade to Private Access
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
Upgrade to include contributions in private repositories for more
|
||||
complete and accurate stats.
|
||||
</p>
|
||||
@@ -89,12 +95,15 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
{/* Delete Account Button */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
<span className="xl:text-lg text-red-600">Delete Account</span>
|
||||
<span className="xl:text-lg text-error text-shadow-none">
|
||||
Delete Account
|
||||
</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
This will delete your GitHub-Stats-Extended account and then redirect
|
||||
you to a GitHub screen where you can revoke your access token.
|
||||
</p>
|
||||
@@ -103,12 +112,13 @@ export function LoginAccountManagement(): JSX.Element {
|
||||
{/* Logout Button */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[320px]"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="xl:text-lg">Log Out</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/70 flex-1">
|
||||
Log out from GitHub-Stats-Extended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function LoginBox(props: LoginBoxProps): JSX.Element {
|
||||
<div className="h-full flex flex-wrap">
|
||||
<div className={clsx("md:flex", { "opacity-25": isOpaque })}>
|
||||
<div className="lg:block lg:w-3/5 lg:p-8">
|
||||
<div className="bg-gray-200 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
|
||||
<div className="bg-base-300 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,23 +7,26 @@ import {
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../../constants";
|
||||
import { CardType } from "../../../../models/CardType";
|
||||
import { cardUrl } from "../../../../models/CardUrl";
|
||||
import { useTheme } from "../../../../redux/selectors/themeSelectors";
|
||||
|
||||
const cards: Array<{ demoImageSrc: string }> = [
|
||||
{
|
||||
demoImageSrc: `/pin?repo=${DEMO_REPO}&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/top-langs?username=${DEMO_USER}&langs_count=4&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `?username=${DEMO_USER}&include_all_commits=true&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/gist?id=${DEMO_GIST}&disable_animations=true`,
|
||||
},
|
||||
const cards = [
|
||||
cardUrl(CardType.PIN).repo(DEMO_REPO).disableAnimations(),
|
||||
cardUrl(CardType.TOP_LANGS)
|
||||
.username(DEMO_USER)
|
||||
.langsCount(4)
|
||||
.disableAnimations(),
|
||||
cardUrl(CardType.STATS)
|
||||
.username(DEMO_USER)
|
||||
.includeAllCommits()
|
||||
.disableAnimations(),
|
||||
cardUrl(CardType.WAKATIME)
|
||||
.username(DEMO_WAKATIME_USER)
|
||||
.langsCount(6)
|
||||
.cardWidth(450)
|
||||
.disableAnimations(),
|
||||
cardUrl(CardType.GIST).gistId(DEMO_GIST).disableAnimations(),
|
||||
];
|
||||
|
||||
function getCardXPosition(cardIndex: number): number {
|
||||
@@ -39,22 +42,28 @@ function getCardXPosition(cardIndex: number): number {
|
||||
}
|
||||
|
||||
export function LoginBoxDemoCards(): JSX.Element {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full lg:w-2/5 flex lg:flex-col lg:p-8 relative overflow-hidden">
|
||||
<div className="relative w-full h-full">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.demoImageSrc}
|
||||
style={{
|
||||
left: `${getCardXPosition(index)}%`,
|
||||
position: "relative",
|
||||
zoom: "0.5",
|
||||
marginBottom: "1%",
|
||||
}}
|
||||
>
|
||||
<CardImage imageSrc={card.demoImageSrc} compact={false} stage={0} />
|
||||
</div>
|
||||
))}
|
||||
{cards.map((card, index) => {
|
||||
// Show dark-themed demo cards in dark mode so they fit the surroundings.
|
||||
const demoCard = isDark ? card.theme("github_dark") : card;
|
||||
return (
|
||||
<div
|
||||
key={demoCard.toString()}
|
||||
style={{
|
||||
left: `${getCardXPosition(index)}%`,
|
||||
position: "relative",
|
||||
zoom: "0.5",
|
||||
marginBottom: "1%",
|
||||
}}
|
||||
>
|
||||
<CardImage card={demoCard} compact={false} stage={0} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,24 +20,30 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
||||
<LoginBox>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<a href={GITHUB_PUBLIC_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[260px] text-white bg-blue-500 hover:bg-blue-600">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 flex justify-center items-center w-[260px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">GitHub Public Access</span>
|
||||
GitHub Public Access
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/80 flex-1">
|
||||
Generate stats based on your contributions in public repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100">
|
||||
<Button
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[260px]"
|
||||
>
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">GitHub Private Access</span>
|
||||
GitHub Private Access
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/80 flex-1">
|
||||
Include contributions from private repositories for more complete and
|
||||
accurate stats.
|
||||
</p>
|
||||
@@ -45,12 +51,13 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
variant="soft"
|
||||
className="h-12 flex justify-center items-center w-[260px]"
|
||||
onClick={onContinueAsGuest}
|
||||
>
|
||||
<span className="ml-2 xl:text-lg">Continue as Guest</span>
|
||||
Continue as Guest
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
<p className="text-sm text-base-content/80 flex-1">
|
||||
Explore options using sample data. Insert your own username in the
|
||||
last step.
|
||||
</p>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../constants";
|
||||
import { CardType } from "../../../models/CardType";
|
||||
import { cardUrl } from "../../../models/CardUrl";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||
import { useUserId } from "../../../redux/selectors/userSelectors";
|
||||
|
||||
interface SelectCardStageProps {
|
||||
@@ -21,12 +24,13 @@ export function SelectCardStage({
|
||||
onCardTypeChange,
|
||||
}: SelectCardStageProps): JSX.Element {
|
||||
const userId = useUserId(DEMO_USER);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const options = useMemo<
|
||||
Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
demoImageSrc: string;
|
||||
demoCard: CardUrlBuilder;
|
||||
cardType: CardType;
|
||||
}>
|
||||
>(
|
||||
@@ -34,33 +38,36 @@ export function SelectCardStage({
|
||||
{
|
||||
title: "GitHub Stats Card",
|
||||
description: "your overall GitHub statistics",
|
||||
demoImageSrc: `?username=${userId}&include_all_commits=true`,
|
||||
demoCard: cardUrl(CardType.STATS).username(userId).includeAllCommits(),
|
||||
cardType: CardType.STATS,
|
||||
},
|
||||
{
|
||||
title: "Top Languages Card",
|
||||
description: "your most frequently used languages",
|
||||
demoImageSrc: `/top-langs?username=${userId}&langs_count=4`,
|
||||
demoCard: cardUrl(CardType.TOP_LANGS).username(userId).langsCount(4),
|
||||
cardType: CardType.TOP_LANGS,
|
||||
},
|
||||
{
|
||||
title: "GitHub Extra Pin",
|
||||
description:
|
||||
"pin more than 6 repositories in your profile using a GitHub profile readme",
|
||||
demoImageSrc: `/pin?repo=${DEMO_REPO}`,
|
||||
demoCard: cardUrl(CardType.PIN).repo(DEMO_REPO),
|
||||
cardType: CardType.PIN,
|
||||
},
|
||||
{
|
||||
title: "GitHub Gist Pin",
|
||||
description:
|
||||
"pin gists in your GitHub profile using a GitHub profile readme",
|
||||
demoImageSrc: `/gist?id=${DEMO_GIST}`,
|
||||
demoCard: cardUrl(CardType.GIST).gistId(DEMO_GIST),
|
||||
cardType: CardType.GIST,
|
||||
},
|
||||
{
|
||||
title: "WakaTime Stats Card",
|
||||
description: "your coding activity from WakaTime",
|
||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450`,
|
||||
demoCard: cardUrl(CardType.WAKATIME)
|
||||
.username(DEMO_WAKATIME_USER)
|
||||
.langsCount(6)
|
||||
.cardWidth(450),
|
||||
cardType: CardType.WAKATIME,
|
||||
},
|
||||
],
|
||||
@@ -69,25 +76,31 @@ export function SelectCardStage({
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
{options.map((card) => (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={card.cardType}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCardTypeChange(card.cardType);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
imageSrc={card.demoImageSrc}
|
||||
selected={selectedCardType === card.cardType}
|
||||
fixedSize
|
||||
stage={1}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{options.map((card) => {
|
||||
// Show dark-themed demo cards in dark mode so they fit the surroundings.
|
||||
const demoCard = isDark
|
||||
? card.demoCard.theme("github_dark")
|
||||
: card.demoCard;
|
||||
return (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={card.cardType}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCardTypeChange(card.cardType);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
card={demoCard}
|
||||
selected={selectedCardType === card.cardType}
|
||||
fixedSize
|
||||
stage={1}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { themes } from "@stats-organization/github-readme-stats-core";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Card } from "../../../components/Card/Card";
|
||||
import {
|
||||
getCardThemeBackdrop,
|
||||
getThemeSortRank,
|
||||
} from "../../../components/Card/themeBackdrop";
|
||||
import type { CardUrlBuilder } from "../../../models/CardUrl";
|
||||
import { useTheme } from "../../../redux/selectors/themeSelectors";
|
||||
|
||||
const excludedThemes = [
|
||||
"merko",
|
||||
@@ -12,53 +18,65 @@ const excludedThemes = [
|
||||
"holi",
|
||||
];
|
||||
|
||||
const themeList = Object.keys(themes).filter(
|
||||
(myTheme) => !excludedThemes.includes(myTheme),
|
||||
);
|
||||
// Light themes first, adaptive themes in the middle, dark themes last.
|
||||
const themeList = Object.keys(themes)
|
||||
.filter((myTheme) => !excludedThemes.includes(myTheme))
|
||||
.sort((a, b) => getThemeSortRank(a) - getThemeSortRank(b));
|
||||
|
||||
interface ThemeStageProps {
|
||||
fullSuffix: string;
|
||||
card: CardUrlBuilder;
|
||||
theme: string;
|
||||
onThemeChange: (theme: string) => void;
|
||||
}
|
||||
|
||||
export function ThemeStage({
|
||||
theme,
|
||||
fullSuffix,
|
||||
card,
|
||||
onThemeChange,
|
||||
}: ThemeStageProps): JSX.Element {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap">
|
||||
{themeList.map((myTheme) => (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={myTheme}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onThemeChange(myTheme);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={myTheme}
|
||||
description=""
|
||||
imageSrc={`${fullSuffix}&theme=${myTheme}`}
|
||||
selected={theme === myTheme}
|
||||
stage={3}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{themeList.map((myTheme) => {
|
||||
const themeColors = themes[myTheme as keyof typeof themes];
|
||||
return (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={myTheme}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onThemeChange(myTheme);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={myTheme}
|
||||
description=""
|
||||
card={card.theme(myTheme)}
|
||||
selected={theme === myTheme}
|
||||
stage={3}
|
||||
backgroundColor={getCardThemeBackdrop(myTheme, isDark)}
|
||||
titleColor={`#${
|
||||
myTheme === "ambient_gradient" && !isDark
|
||||
? (themes["ambient_gradient"].bg_color.split(",")[1] ?? "")
|
||||
: themeColors.title_color
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pl-10 pr-10">
|
||||
For more theme options check the{" "}
|
||||
{"For more theme options check the "}
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md#themes"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
className="underline text-primary"
|
||||
>
|
||||
customization documentation
|
||||
</a>{" "}
|
||||
after you copied your card URL in step 5.
|
||||
</a>
|
||||
{" after you copied your card URL in step 5."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import axios from "axios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
HOST,
|
||||
} from "../../constants";
|
||||
import { CardType } from "../../models/CardType";
|
||||
import type { CardUrlBuilder } from "../../models/CardUrl";
|
||||
|
||||
/** Per-card metadata for the final display stage. */
|
||||
export interface CardDescriptor {
|
||||
/** Sample identity shown to guests (e.g. a sample username or repo). */
|
||||
guestHint: string;
|
||||
/** URL the card image links to when clicked. */
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface UseCardDescriptorOptions {
|
||||
selectedCard: CardType;
|
||||
/** Themed builder used to derive the stats/top-langs card link. */
|
||||
themeBuilder: CardUrlBuilder;
|
||||
repo: string;
|
||||
userId: string;
|
||||
wakatimeUser: string;
|
||||
gist: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-card metadata (guest hint + link) for the display stage,
|
||||
* fetching the Gist's public URL as a side effect when needed.
|
||||
*/
|
||||
export function useCardDescriptor({
|
||||
selectedCard,
|
||||
themeBuilder,
|
||||
repo,
|
||||
userId,
|
||||
wakatimeUser,
|
||||
gist,
|
||||
}: UseCardDescriptorOptions): CardDescriptor {
|
||||
const [gistUrl, setGistUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchGistURL() {
|
||||
try {
|
||||
const result = await axios.get<{ html_url: string }>(
|
||||
`https://api.github.com/gists/${gist}`,
|
||||
);
|
||||
setGistUrl(result.data.html_url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
void fetchGistURL();
|
||||
}, [gist]);
|
||||
|
||||
return useMemo<CardDescriptor>(() => {
|
||||
const pinRepo = repo.includes("/") ? repo : `${userId}/${repo}`;
|
||||
const statsCardLink = themeBuilder.toApiUrl(HOST);
|
||||
|
||||
const descriptors: Record<CardType, CardDescriptor> = {
|
||||
[CardType.STATS]: {
|
||||
guestHint: `username "${DEMO_USER}"`,
|
||||
link: statsCardLink,
|
||||
},
|
||||
[CardType.TOP_LANGS]: {
|
||||
guestHint: `username "${DEMO_USER}"`,
|
||||
link: statsCardLink,
|
||||
},
|
||||
[CardType.PIN]: {
|
||||
guestHint: `repo "${DEMO_REPO}"`,
|
||||
link: `https://github.com/${pinRepo}`,
|
||||
},
|
||||
[CardType.GIST]: {
|
||||
guestHint: `Gist ID "${DEMO_GIST}"`,
|
||||
link: gistUrl,
|
||||
},
|
||||
[CardType.WAKATIME]: {
|
||||
guestHint: `WakaTime username "${DEMO_WAKATIME_USER}"`,
|
||||
link: `https://wakatime.com/@${wakatimeUser}`,
|
||||
},
|
||||
};
|
||||
|
||||
return descriptors[selectedCard];
|
||||
}, [selectedCard, themeBuilder, repo, userId, gistUrl, wakatimeUser]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { isDarkTheme, setTheme as setThemeAction } from "../slices/theme";
|
||||
import type { StoreState } from "../store";
|
||||
|
||||
/**
|
||||
* Reads the active DaisyUI theme from the Redux store and exposes a setter
|
||||
* that persists the choice and updates the `data-theme` attribute.
|
||||
*/
|
||||
export function useTheme(): {
|
||||
theme: string;
|
||||
isDark: boolean;
|
||||
setTheme: (theme: string) => void;
|
||||
} {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useSelector((state: StoreState) => state.theme.theme);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(next: string) => {
|
||||
dispatch(setThemeAction(next));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return { theme, isDark: isDarkTheme(theme), setTheme };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface ThemeOption {
|
||||
/** DaisyUI theme name, used as the `data-theme` value. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** Whether the theme has a dark background (drives toast styling, etc.). */
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes exposed in the theme picker.
|
||||
* Keep in sync with the `themes:` list in `index.css`.
|
||||
*/
|
||||
export const THEMES: ReadonlyArray<ThemeOption> = [
|
||||
{ name: "light", label: "Light", isDark: false },
|
||||
{ name: "dark", label: "Dark", isDark: true },
|
||||
];
|
||||
|
||||
const DEFAULT_LIGHT_THEME = "light";
|
||||
const DEFAULT_DARK_THEME = "dark";
|
||||
|
||||
function isValidTheme(name: string | null): name is string {
|
||||
return !!name && THEMES.some((theme) => theme.name === name);
|
||||
}
|
||||
|
||||
export function isDarkTheme(name: string): boolean {
|
||||
return THEMES.find((theme) => theme.name === name)?.isDark ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Exported so the inferred store type can name it (see the note in `user.ts`).
|
||||
*/
|
||||
export interface ThemeState {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
function getInitialTheme(): string {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (isValidTheme(stored)) {
|
||||
return stored;
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? DEFAULT_DARK_THEME
|
||||
: DEFAULT_LIGHT_THEME;
|
||||
}
|
||||
|
||||
const initialState: ThemeState = {
|
||||
theme: getInitialTheme(),
|
||||
};
|
||||
|
||||
const themeSlice = createSlice({
|
||||
name: "theme",
|
||||
initialState,
|
||||
reducers: {
|
||||
setTheme: (state, action: PayloadAction<string>) => {
|
||||
const theme = action.payload;
|
||||
localStorage.setItem("theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
state.theme = theme;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setTheme } = themeSlice.actions;
|
||||
|
||||
export default themeSlice.reducer;
|
||||
@@ -3,11 +3,13 @@ import { configureStore } from "@reduxjs/toolkit";
|
||||
import { USE_LOGGER } from "../constants";
|
||||
|
||||
import { loggerMiddleware } from "./logger";
|
||||
import theme from "./slices/theme";
|
||||
import user from "./slices/user";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user,
|
||||
theme,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => {
|
||||
const middleware = getDefaultMiddleware();
|
||||
|
||||
@@ -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).
|
||||
|
||||
> [!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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
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
|
||||
|
||||
|
||||
+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!
|
||||
|
||||
[](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>
|
||||
|
||||
|
||||
+11
-29
@@ -2,12 +2,14 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import eslintReact from "@eslint-react/eslint-plugin";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
|
||||
import {
|
||||
createTypeScriptImportResolver,
|
||||
defaultConditionNames,
|
||||
} from "eslint-import-resolver-typescript";
|
||||
import { importX } from "eslint-plugin-import-x";
|
||||
import { default as jsdoc } from "eslint-plugin-jsdoc";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
import { default as tseslint } from "typescript-eslint";
|
||||
|
||||
@@ -26,14 +28,7 @@ export default defineConfig(
|
||||
/** Keep in sync with `tsconfig.base.json#customConditions` */
|
||||
"@stats/source",
|
||||
|
||||
"types",
|
||||
"import",
|
||||
|
||||
"require",
|
||||
"node",
|
||||
"node-addons",
|
||||
"browser",
|
||||
"default",
|
||||
...defaultConditionNames,
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -150,6 +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
|
||||
"jsdoc/require-returns": "off",
|
||||
"jsdoc/require-returns-description": "off",
|
||||
@@ -172,23 +171,6 @@ export default defineConfig(
|
||||
},
|
||||
{
|
||||
files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"],
|
||||
plugins: {
|
||||
react,
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/no-array-index-key": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
...eslintReact.configs["recommended-typescript"],
|
||||
},
|
||||
);
|
||||
|
||||
+13
-14
@@ -2,27 +2,26 @@
|
||||
"name": "@stats-organization/root",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7",
|
||||
"devDependencies": {
|
||||
"@eslint-react/eslint-plugin": "5.17.1",
|
||||
"@eslint/compat": "2.1.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@types/node": "24.13.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@types/node": "24.13.3",
|
||||
"@vitest/coverage-v8": "catalog:default",
|
||||
"eslint": "10.4.1",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-jsdoc": "63.0.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.6.0",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-jsdoc": "63.1.0",
|
||||
"globals": "17.7.0",
|
||||
"husky": "9.1.7",
|
||||
"knip": "6.16.1",
|
||||
"lint-staged": "17.0.7",
|
||||
"prettier": "3.8.4",
|
||||
"turbo": "2.9.17",
|
||||
"knip": "6.27.0",
|
||||
"lint-staged": "17.0.8",
|
||||
"prettier": "3.9.5",
|
||||
"turbo": "2.10.5",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.61.0",
|
||||
"typescript-eslint": "8.64.0",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stats-organization/github-readme-stats-core",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"type": "module",
|
||||
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
||||
"bugs": {
|
||||
@@ -83,7 +83,7 @@
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@uppercod/css-to-object": "1.1.1",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"jsdom": "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 { findInvalidColor } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -28,6 +27,23 @@ export default async (
|
||||
},
|
||||
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)) {
|
||||
return {
|
||||
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 {
|
||||
const gistData = await fetchGist(id, pat);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderStatsCard } from "../cards/stats.js";
|
||||
import { findInvalidColor } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -46,6 +45,24 @@ export default async (
|
||||
},
|
||||
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)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderRepoCard } from "../cards/repo.js";
|
||||
import { findInvalidColor } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -36,6 +35,23 @@ export default async (
|
||||
},
|
||||
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)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderTopLanguages } from "../cards/top-languages.js";
|
||||
import { findInvalidColor } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -40,6 +39,23 @@ export default async (
|
||||
},
|
||||
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)) {
|
||||
return {
|
||||
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 (
|
||||
layout !== undefined &&
|
||||
(typeof layout !== "string" ||
|
||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
@@ -80,8 +113,7 @@ export default async (
|
||||
|
||||
if (
|
||||
stats_format !== undefined &&
|
||||
(typeof stats_format !== "string" ||
|
||||
!["bytes", "percentages"].includes(stats_format))
|
||||
!["bytes", "percentages"].includes(stats_format)
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderWakatimeCard } from "../cards/wakatime.js";
|
||||
import { findInvalidColor } from "../common/color.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
@@ -34,6 +33,23 @@ export default async ({
|
||||
display_format,
|
||||
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)) {
|
||||
return {
|
||||
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 {
|
||||
const stats = await fetchWakatimeStats({ username, api_domain });
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Calculates the exponential cdf.
|
||||
*
|
||||
* @param {number} x The value.
|
||||
* @returns {number} The exponential cdf.
|
||||
* @param x The value.
|
||||
* @returns The exponential cdf.
|
||||
*/
|
||||
function exponential_cdf(x) {
|
||||
function exponential_cdf(x: number): number {
|
||||
return 1 - 2 ** -x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the log normal cdf.
|
||||
*
|
||||
* @param {number} x The value.
|
||||
* @returns {number} The log normal cdf.
|
||||
* @param x The value.
|
||||
* @returns The log normal cdf.
|
||||
*/
|
||||
function log_normal_cdf(x) {
|
||||
function log_normal_cdf(x: number): number {
|
||||
// approximation
|
||||
return x / (1 + x);
|
||||
}
|
||||
@@ -22,16 +22,16 @@ function log_normal_cdf(x) {
|
||||
/**
|
||||
* Calculates the users rank.
|
||||
*
|
||||
* @param {object} params Parameters on which the user's rank depends.
|
||||
* @param {boolean} params.all_commits Whether `include_all_commits` was used.
|
||||
* @param {number} params.commits Number of commits.
|
||||
* @param {number} params.prs The number of pull requests.
|
||||
* @param {number} params.issues The number of issues.
|
||||
* @param {number} params.reviews The number of reviews.
|
||||
* @param {number} params.repos Total number of repos.
|
||||
* @param {number} params.stars The number of stars.
|
||||
* @param {number} params.followers The number of followers.
|
||||
* @returns {{ level: string, percentile: number }} The users rank.
|
||||
* @param params Parameters on which the user's rank depends.
|
||||
* @param params.all_commits Whether `include_all_commits` was used.
|
||||
* @param params.commits Number of commits.
|
||||
* @param params.prs The number of pull requests.
|
||||
* @param params.issues The number of issues.
|
||||
* @param params.reviews The number of reviews.
|
||||
* @param params.repos Total number of repos (accepted for compatibility, unused in the calculation).
|
||||
* @param params.stars The number of stars.
|
||||
* @param params.followers The number of followers.
|
||||
* @returns The users rank.
|
||||
*/
|
||||
function calculateRank({
|
||||
all_commits,
|
||||
@@ -39,11 +39,18 @@ function calculateRank({
|
||||
prs,
|
||||
issues,
|
||||
reviews,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
repos, // unused
|
||||
stars,
|
||||
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,
|
||||
COMMITS_WEIGHT = 2;
|
||||
const PRS_MEDIAN = 50,
|
||||
@@ -79,6 +86,9 @@ function calculateRank({
|
||||
TOTAL_WEIGHT;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// @ts-check
|
||||
|
||||
import { default as Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.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 { encodeHTML } from "../common/html.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
@@ -34,20 +32,29 @@ const DESCRIPTION_MAX_LINES = 3;
|
||||
* @param {string} textColor The color of the text.
|
||||
* @returns {string} Wrapped repo description SVG object.
|
||||
*/
|
||||
const getBadgeSVG = (label, textColor, xOffset = 0) => `
|
||||
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
||||
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
||||
<text
|
||||
x="23" y="-5"
|
||||
alignment-baseline="central"
|
||||
dominant-baseline="central"
|
||||
text-anchor="middle"
|
||||
fill="${textColor}"
|
||||
>
|
||||
${label}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
const getBadgeSVG = (label, textColor, xOffset = 0) => {
|
||||
if (!isPrefixedHexColor(textColor)) {
|
||||
throw new Error(`Invalid text color: "${textColor}"`);
|
||||
}
|
||||
if (!Number.isFinite(xOffset)) {
|
||||
throw new Error(`Invalid xOffset: "${xOffset}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
||||
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
||||
<text
|
||||
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.
|
||||
@@ -112,6 +119,7 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
});
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
|
||||
const encodedUsername = encodeURIComponent(username);
|
||||
const STATS = {};
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
@@ -119,7 +127,7 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
label: i18n.t("repocard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
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")) {
|
||||
@@ -128,7 +136,7 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
label: i18n.t("repocard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
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")) {
|
||||
@@ -137,7 +145,7 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
label: i18n.t("repocard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
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")) {
|
||||
@@ -146,7 +154,7 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
label: i18n.t("repocard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
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")) {
|
||||
@@ -155,7 +163,7 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
label: i18n.t("repocard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
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 { I18n } from "../common/I18n.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { CustomError } from "../common/error.js";
|
||||
import { kFormatter } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { icons, rankIcon } from "../common/icons.js";
|
||||
import { buildSearchFilter, clampValue } from "../common/ops.js";
|
||||
import { flexLayout, measureText } from "../common/render.js";
|
||||
@@ -54,8 +53,10 @@ const LONG_LOCALES = [
|
||||
/**
|
||||
* 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 {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 {number} params.value The value to display.
|
||||
* @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 {string} params.numberFormat The format 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.
|
||||
* @returns {string} The stats card text item SVG object.
|
||||
*/
|
||||
@@ -85,6 +86,16 @@ const createTextNode = ({
|
||||
link,
|
||||
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 =
|
||||
typeof numberPrecision === "number" && !isNaN(numberPrecision)
|
||||
? clampValue(numberPrecision, 0, 2)
|
||||
@@ -111,7 +122,7 @@ const createTextNode = ({
|
||||
${iconSvg}
|
||||
<text class="stat ${
|
||||
bold ? " bold" : "not_bold"
|
||||
}" ${labelOffset} y="12.5">${label}:</text>
|
||||
}" ${labelOffset} y="12.5">${encodeHTML(label)}:</text>
|
||||
<text
|
||||
class="stat ${bold ? " bold" : "not_bold"}"
|
||||
x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
|
||||
@@ -424,13 +435,14 @@ const renderStatsCard = (
|
||||
}
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
|
||||
const encodedUsername = encodeURIComponent(username);
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("statcard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
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")) {
|
||||
@@ -439,7 +451,7 @@ const renderStatsCard = (
|
||||
label: i18n.t("statcard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
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")) {
|
||||
@@ -448,7 +460,7 @@ const renderStatsCard = (
|
||||
label: i18n.t("statcard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
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")) {
|
||||
@@ -457,7 +469,7 @@ const renderStatsCard = (
|
||||
label: i18n.t("statcard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
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")) {
|
||||
@@ -466,7 +478,7 @@ const renderStatsCard = (
|
||||
label: i18n.t("statcard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
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 { 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 { encodeHTML } from "../common/html.js";
|
||||
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
|
||||
import {
|
||||
createProgressNode,
|
||||
@@ -246,8 +249,8 @@ const createProgressTextNode = ({
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
|
||||
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</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">${encodeHTML(displayValue)}</text>`}
|
||||
${createProgressNode({
|
||||
x: 0,
|
||||
y: 25,
|
||||
@@ -287,11 +290,15 @@ const createCompactLangNode = ({
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const color = lang.color || "#858585";
|
||||
|
||||
if (!isPrefixedHexColor(color)) {
|
||||
throw new Error(`Invalid language color: "${color}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<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>
|
||||
</g>
|
||||
`;
|
||||
@@ -441,6 +448,11 @@ const renderCompactLayout = (
|
||||
let progressOffset = 0;
|
||||
const compactProgressBar = langs
|
||||
.map((lang) => {
|
||||
const langColor = lang.color || DEFAULT_LANG_COLOR;
|
||||
if (!isPrefixedHexColor(langColor)) {
|
||||
throw new Error(`Invalid language color: "${langColor}"`);
|
||||
}
|
||||
|
||||
const percentage = parseFloat(
|
||||
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
|
||||
);
|
||||
@@ -455,7 +467,7 @@ const renderCompactLayout = (
|
||||
y="0"
|
||||
width="${progress}"
|
||||
height="8"
|
||||
fill="${lang.color || "#858585"}"
|
||||
fill="${langColor}"
|
||||
/>
|
||||
`;
|
||||
progressOffset += percentage;
|
||||
@@ -516,18 +528,23 @@ const renderDonutVerticalLayout = (
|
||||
|
||||
// Generate each donut vertical chart part
|
||||
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 circleLength = totalCircleLength * (percentage / 100);
|
||||
const delay = startDelayCoefficient * 100;
|
||||
|
||||
circles.push(`
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<circle
|
||||
<circle
|
||||
cx="150"
|
||||
cy="100"
|
||||
r="${radius}"
|
||||
fill="transparent"
|
||||
stroke="${lang.color}"
|
||||
stroke="${langColor}"
|
||||
stroke-width="25"
|
||||
stroke-dasharray="${totalCircleLength}"
|
||||
stroke-dashoffset="${indent}"
|
||||
@@ -591,6 +608,11 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
||||
|
||||
// Generate each pie chart part
|
||||
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) {
|
||||
paths.push(`
|
||||
<circle
|
||||
@@ -598,7 +620,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
||||
cy="${centerY}"
|
||||
r="${radius}"
|
||||
stroke="none"
|
||||
fill="${lang.color}"
|
||||
fill="${langColor}"
|
||||
data-testid="lang-pie"
|
||||
size="100"
|
||||
/>
|
||||
@@ -631,7 +653,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
||||
data-testid="lang-pie"
|
||||
size="${percentage}"
|
||||
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>
|
||||
`);
|
||||
@@ -718,12 +740,22 @@ const renderDonutLayout = (
|
||||
statsFormat,
|
||||
hideValues,
|
||||
) => {
|
||||
if (!Number.isFinite(width)) {
|
||||
throw new Error(`Invalid width: "${width}"`);
|
||||
}
|
||||
|
||||
const centerX = width / 3;
|
||||
const centerY = width / 3;
|
||||
const radius = centerX - 60;
|
||||
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) =>
|
||||
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
|
||||
);
|
||||
@@ -785,10 +817,14 @@ const renderDonutLayout = (
|
||||
* @returns {string} No languages data SVG node string.
|
||||
*/
|
||||
const noLanguagesDataNode = ({ color, text, layout }) => {
|
||||
if (!isPrefixedHexColor(color)) {
|
||||
throw new Error(`Invalid text color: "${color}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
<text x="${
|
||||
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 { 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 { clampValue, lowercaseTrim } from "../common/ops.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.
|
||||
*/
|
||||
const noCodingActivityNode = ({ color, text }) => {
|
||||
if (!isPrefixedHexColor(color)) {
|
||||
throw new Error(`Invalid text color: "${color}"`);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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
|
||||
const color = languageColors[lang.name] || "#858585";
|
||||
const value = formatLanguageValue({ display_format, lang });
|
||||
@@ -68,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => {
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||
${lang.name} - ${value}
|
||||
${encodeHTML(lang.name)} - ${encodeHTML(value)}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
@@ -127,6 +137,13 @@ const createTextNode = ({
|
||||
progressBarBackgroundColor,
|
||||
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 cardProgress = hideProgress
|
||||
? null
|
||||
@@ -144,12 +161,12 @@ const createTextNode = ({
|
||||
|
||||
return `
|
||||
<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
|
||||
class="stat"
|
||||
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
|
||||
y="12.5"
|
||||
>${value}</text>
|
||||
>${encodeHTML(value)}</text>
|
||||
${cardProgress}
|
||||
</g>
|
||||
`;
|
||||
@@ -181,11 +198,15 @@ const recalculatePercentages = (languages) => {
|
||||
* @param {string} colors.textColor The text color.
|
||||
* @returns {string} Card CSS styles.
|
||||
*/
|
||||
const getStyles = ({
|
||||
const getStyles = function ({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
titleColor,
|
||||
textColor,
|
||||
}) => {
|
||||
}) {
|
||||
if (!isPrefixedHexColor(textColor)) {
|
||||
throw new Error(`Invalid text color: "${textColor}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||
@@ -379,7 +400,9 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
|
||||
progressBarColor: titleColor,
|
||||
// @ts-ignore
|
||||
progressBarBackgroundColor:
|
||||
textColor === titleColor ? bgColor : textColor,
|
||||
textColor === titleColor
|
||||
? "#fff0" // transparent
|
||||
: textColor,
|
||||
hideProgress: hide_progress,
|
||||
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";
|
||||
|
||||
/**
|
||||
* @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";
|
||||
|
||||
/**
|
||||
* @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 = {
|
||||
MAX_RETRY:
|
||||
@@ -28,16 +26,21 @@ const SECONDARY_ERROR_MESSAGES = {
|
||||
* Custom error class to handle custom GRS errors.
|
||||
*/
|
||||
class CustomError extends Error {
|
||||
type: string;
|
||||
secondaryMessage: string;
|
||||
|
||||
/**
|
||||
* Custom error constructor.
|
||||
*
|
||||
* @param {string} message Error message.
|
||||
* @param {string} type Error type.
|
||||
* @param message Error message.
|
||||
* @param type Error type.
|
||||
*/
|
||||
constructor(message, type) {
|
||||
constructor(message: string, type: string) {
|
||||
super(message);
|
||||
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";
|
||||
@@ -53,13 +56,16 @@ class CustomError extends Error {
|
||||
* Missing query parameter class.
|
||||
*/
|
||||
class MissingParamError extends Error {
|
||||
missedParams: Array<string>;
|
||||
secondaryMessage: string | undefined;
|
||||
|
||||
/**
|
||||
* Missing query parameter error constructor.
|
||||
*
|
||||
* @param {string[]} missedParams An array of missing parameters names.
|
||||
* @param {string=} secondaryMessage Optional secondary message to display.
|
||||
* @param missedParams An array of missing parameters names.
|
||||
* @param secondaryMessage Optional secondary message to display.
|
||||
*/
|
||||
constructor(missedParams, secondaryMessage) {
|
||||
constructor(missedParams: Array<string>, secondaryMessage?: string) {
|
||||
const msg = `Missing params ${missedParams
|
||||
.map((p) => `"${p}"`)
|
||||
.join(", ")} make sure you pass the parameters in URL`;
|
||||
@@ -72,10 +78,10 @@ class MissingParamError extends Error {
|
||||
/**
|
||||
* Retrieve secondary message from an error object.
|
||||
*
|
||||
* @param {Error} err The error object.
|
||||
* @returns {string|undefined} The secondary message if available, otherwise undefined.
|
||||
* @param err The error object.
|
||||
* @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"
|
||||
? err.secondaryMessage
|
||||
: undefined;
|
||||
@@ -1,16 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { splitWrappedText } from "./render.js";
|
||||
|
||||
/**
|
||||
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
||||
*
|
||||
* @param {number} num The number to format.
|
||||
* @param {number=} precision The number of decimal places to include.
|
||||
* @returns {string|number} The formatted number.
|
||||
* @param num The number to format.
|
||||
* @param precision The number of decimal places to include.
|
||||
* @returns The formatted number.
|
||||
*/
|
||||
const kFormatter = (num, precision) => {
|
||||
const kFormatter = (num: number, precision?: number): string | number => {
|
||||
const abs = Math.abs(num);
|
||||
const sign = Math.sign(num);
|
||||
|
||||
@@ -22,17 +20,17 @@ const kFormatter = (num, precision) => {
|
||||
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.
|
||||
*
|
||||
* @param {number} bytes The number of bytes to convert.
|
||||
* @returns {string} The human-readable representation of bytes.
|
||||
* @param bytes The number of bytes to convert.
|
||||
* @returns The human-readable representation of bytes.
|
||||
* @throws {Error} If bytes is negative or too large.
|
||||
*/
|
||||
const formatBytes = (bytes) => {
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 0) {
|
||||
throw new Error("Bytes must be a non-negative number");
|
||||
}
|
||||
@@ -45,23 +43,29 @@ const formatBytes = (bytes) => {
|
||||
const base = 1024;
|
||||
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");
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {string} text Text to split.
|
||||
* @param {number} width Available wrap width in px.
|
||||
* @param {number} fontSize Font size in px.
|
||||
* @param {number} maxLines Maximum number of lines.
|
||||
* @returns {string[]} Array of lines.
|
||||
* @param text Text to split.
|
||||
* @param width Available wrap width in px.
|
||||
* @param fontSize Font size in px.
|
||||
* @param maxLines Maximum number 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 lines = wrapped
|
||||
.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
|
||||
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
|
||||
@@ -1,18 +1,13 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Encode string as HTML.
|
||||
*
|
||||
* @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 (
|
||||
str
|
||||
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
|
||||
return "&#" + i.charCodeAt(0) + ";";
|
||||
.replace(/[\u00A0-\u9999<>&"'](?!#)/gim, (i: string) => {
|
||||
return `&#${i.charCodeAt(0)};`;
|
||||
})
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.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