diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..99b302c --- /dev/null +++ b/api/index.ts @@ -0,0 +1,74 @@ +import { renderError, parseBoolean, parseArray, CONSTANTS} from '../src/common/utils' +import { fetchProfile } from '../src/fetcher' +import renderStatsCard from '../src/cards/profileCard' +import blacklist from '../src/common/blacklist' +import { Request, Response } from 'express'; +import ReactDOMServer from 'react-dom/server' +import themes from '../themes'; + +export interface query { + username: string + hide?: string + hide_title?: string + hide_border?: string + hide_rank?: string + show_icons?: string + line_height?: string + title_color?: string + icon_color?: string + text_color?: string + bg_color?: string + theme?: keyof typeof themes + cache_seconds?: string +} + +export default async (req: Request, res: Response) => { + const { + username, + hide, + hide_title, + hide_border, + hide_rank, + show_icons, + line_height, + title_color, + icon_color, + text_color, + bg_color, + theme, + } = req.query; + + res.setHeader("Content-Type", "image/svg+xml"); + + if (blacklist.includes(username)) { + return res.send(renderError("Username is in blacklist")); + } + + try { + const data = await fetchProfile(username); + + const cacheSeconds = CONSTANTS.TWO_HOURS + + res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); + + return res.send(ReactDOMServer.renderToStaticMarkup( + renderStatsCard(data, { + hide: parseArray(hide), + show_icons: parseBoolean(show_icons), + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + hide_rank: parseBoolean(hide_rank), + line_height: line_height ? parseInt(line_height , 10) : undefined, + title_color, + icon_color, + text_color, + bg_color, + theme, + })) + ); + } catch (err) { + return res.send( + ReactDOMServer.renderToStaticMarkup(renderError(err.message, err.secondaryMessage)) + ); + } +}; diff --git a/api/top-langs.ts b/api/top-langs.ts index 5484858..473e18e 100644 --- a/api/top-langs.ts +++ b/api/top-langs.ts @@ -1,5 +1,5 @@ import { renderError, clampValue, parseBoolean, parseArray, CONSTANTS} from '../src/common/utils' -import fetchTopLanguages from '../src/fetchers/top-languages-fetcher' +import { fetchTopLanguages } from '../src/fetcher' import renderTopLanguages from '../src/cards/top-languages-card' import blacklist from '../src/common/blacklist' import { Request, Response } from 'express'; diff --git a/src/cards/profileCard.tsx b/src/cards/profileCard.tsx new file mode 100644 index 0000000..5bfc1a6 --- /dev/null +++ b/src/cards/profileCard.tsx @@ -0,0 +1,185 @@ +import { kFormatter, getCardColors, FlexLayout, encodeHTML, getProgress } from '../common/utils' +import { getStyles } from '../getStyles' +import icons from '../common/icons' +import Card from '../common/Card' +import React from 'react' +import themes from '../../themes' + +const createTextNode = ({ + icon, + label, + value, + index, + showIcons +}: { + icon: JSX.Element, + label: string, + value: number, + index: number, + showIcons: boolean +}) => { + const kValue = kFormatter(value); + const staggerDelay = (index + 3) * 150; + + const iconSvg = showIcons + ? ( + + {icon} + + ) + : undefined + return ( + + {iconSvg} + {label}: + {kValue} + + ) +}; + +interface Options { + hide?: Array + show_icons?: boolean + hide_title?: boolean + hide_border?: boolean + hide_rank?: boolean + line_height?: number + title_color?: string + icon_color?: string + text_color?: string + bg_color?: string + theme?: keyof typeof themes +} + +export default (stats: {username: string, xp: number, recentXp: number, level: number}, options: Options = { hide: [] }) => { + const { + username, + xp, + recentXp, + level + } = stats; + const { + hide = [], + show_icons = false, + hide_title = false, + hide_border = false, + hide_rank = false, + line_height = 25, + title_color, + icon_color, + text_color, + bg_color, + theme = "default", + } = options; + + const lheight = line_height + + // returns theme based colors with proper overrides and defaults + const { titleColor, textColor, iconColor, bgColor } = getCardColors({ + title_color, + icon_color, + text_color, + bg_color, + theme, + }); + + // Meta data for creating text nodes with createTextNode function + const STATS = { + xp: { + icon: icons.star, + label: "XP", + value: xp, + id: "xp", + }, + recent_xp: { + icon: icons.commits, + label: 'Recent xp', + value: recentXp, + id: "recent_xp", + }, + }; + + // filter out hidden stats defined by user & create the text nodes + const statItems = Object.keys(STATS) + .filter((key) => !hide.includes(key)) + .map((key, index) => + // create the text nodes, and pass index so that we can calculate the line spacing + createTextNode({ + ...STATS[key], + index, + showIcons: show_icons + }) + ); + + // Calculate the card height depending on how many items there are + // but if rank circle is visible clamp the minimum height to `150` + let height = Math.max( + 45 + (statItems.length + 1) * lheight, + hide_rank ? 0 : 120 + ); + + // Conditionally rendered elements + const rankCircle = hide_rank + ? undefined + : ( + + + + + + lv {level} + + + + ) + + const progress = getProgress(xp + recentXp); + const cssStyles = getStyles({ + titleColor, + textColor, + iconColor, + show_icons, + progress, + }); + + const apostrophe = ["x", "s"].includes(username.slice(-1)) ? "" : "s"; + const card = new Card( + 495, + height, + { + titleColor, + textColor, + iconColor, + bgColor, + }, + `${encodeHTML(username)}'${apostrophe} Code::stats Profile` + ) + + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS(cssStyles); + + return card.render( + <> + {rankCircle} + + {FlexLayout({ + items: statItems, + gap: lheight, + direction: "column", + })} + + + ) +} diff --git a/src/cards/top-languages-card.tsx b/src/cards/top-languages-card.tsx index cc42936..3032f7a 100644 --- a/src/cards/top-languages-card.tsx +++ b/src/cards/top-languages-card.tsx @@ -1,7 +1,6 @@ import { getCardColors, FlexLayout, clampValue } from "../common/utils" import Card from '../common/Card' -import { query } from "../../api/top-langs"; -import { data } from "../fetchers/top-languages-fetcher"; +import { data } from "../fetcher"; import React from 'react' import themes from "../../themes"; diff --git a/src/common/Card.tsx b/src/common/Card.tsx index c1c650c..e19b911 100644 --- a/src/common/Card.tsx +++ b/src/common/Card.tsx @@ -14,7 +14,7 @@ export default class Card { constructor( public width = 100, public height = 100, - public colors: {titleColor?: string | Array, textColor?: string | Array, bgColor?: string | Array} = {}, + public colors: {titleColor?: string | Array, textColor?: string | Array, bgColor?: string | Array, iconColor?: string | Array} = {}, public title = "", public titlePrefixIcon?: string ) {} diff --git a/src/common/icons.tsx b/src/common/icons.tsx index 870c5ce..1588305 100644 --- a/src/common/icons.tsx +++ b/src/common/icons.tsx @@ -1,12 +1,12 @@ import React from 'react' const icons = { - star: , - commits: , - prs: , - issues: , - icon: , - contribs: , - fork: , + star: , + commits: , + prs: , + issues: , + icon: , + contribs: , + fork: , }; export default icons diff --git a/src/common/retryer.ts b/src/common/retryer.ts index 5fcb890..bd29c25 100644 --- a/src/common/retryer.ts +++ b/src/common/retryer.ts @@ -1,42 +1,18 @@ -import { logger, CustomError } from './utils' +import { CustomError, request } from './utils' +import { AxiosPromise } from 'axios'; -const retryer = async (fetcher, variables, retries = 0) => { +const retryer = async >(fetcher: (variables: {login: string}) => T, variables: {login: string}, retries = 0): Promise => { if (retries > 7) { - throw new CustomError("Maximum retries exceeded", CustomError.MAX_RETRY); + throw new CustomError("Maximum retries exceeded", 'MAX_RETRY'); } try { - // try to fetch with the first token since RETRIES is 0 index i'm adding +1 let response = await fetcher( - variables, - process.env[`PAT_${retries + 1}`], - retries - ); + variables + ) - // prettier-ignore - const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; - - // if rate limit is hit increase the RETRIES and recursively call the retryer - // with username, and current RETRIES - if (isRateExceeded) { - logger.log(`PAT_${retries + 1} Failed`); - retries++; - // directly return from the function - return retryer(fetcher, variables, retries); - } - - // finally return the response return response; } catch (err) { - // prettier-ignore - // also checking for bad credentials if any tokens gets invalidated - const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; - - if (isBadCredential) { - logger.log(`PAT_${retries + 1} Failed`); - retries++; - // directly return from the function - return retryer(fetcher, variables, retries); - } + return retryer(fetcher, variables, 1 + retries); } }; diff --git a/src/common/utils.tsx b/src/common/utils.tsx index 4080400..4e7eb98 100644 --- a/src/common/utils.tsx +++ b/src/common/utils.tsx @@ -1,8 +1,9 @@ import React from 'react' -import axios from 'axios' +import axios, { AxiosPromise } from 'axios' import wrap from 'word-wrap' import themes from '../../themes' +import { CodeStatsResponse } from '../fetcher/interface'; export const renderError = (message: string, secondaryMessage = "") => { return ( @@ -13,7 +14,7 @@ export const renderError = (message: string, secondaryMessage = "") => { .gray { fill: #858585 } `} - Something went wrong! file an issue at https://git.io/JJmN9 + Something went wrong! file an issue at https://dze.io/3nL29 {encodeHTML(message)} {secondaryMessage} @@ -33,7 +34,7 @@ export function encodeHTML(str: string) { export function kFormatter(num: number) { return Math.abs(num) > 999 - ? Math.sign(num) * (Math.abs(num) / 1000).toFixed(1) + "k" + ? Math.sign(num) * parseInt((Math.abs(num) / 1000).toFixed(1)) + "k" : Math.sign(num) * Math.abs(num); } @@ -78,16 +79,7 @@ export function fallbackColor(color: string, fallbackColor: Array| strin ); } -export function request(data?: any, headers?: any) { - return axios({ - url: "https://api.github.com/graphql", - method: "post", - headers, - data, - }); -} - -export function codeStatsRequest(data?: any) { +export function request(data: {login: string}): AxiosPromise { return axios({ url: "https://codestats.net/api/users/" + data.login, method: "get", @@ -182,9 +174,7 @@ export const CONSTANTS = { }; export const SECONDARY_ERROR_MESSAGES = { - MAX_RETRY: - "Please add an env variable called PAT_1 with your github token in vercel", - USER_NOT_FOUND: "Make sure the provided username is not an organization", + MAX_RETRY: "Make sur your profile is not private" }; export class CustomError extends Error { @@ -198,3 +188,16 @@ export class CustomError extends Error { static MAX_RETRY = "MAX_RETRY"; static USER_NOT_FOUND = "USER_NOT_FOUND"; } + +export function getLevel(xp: number): number { + return Math.trunc(Math.floor(CONSTANTS.LEVEL_FACTOR * Math.sqrt(xp))) +} + +/** + * Return the progress (0-99)% til next level + * @param xp Xp number + */ +export function getProgress(xp: number): number { + const currentLvl = getLevel(xp) + return Math.trunc((CONSTANTS.LEVEL_FACTOR * Math.sqrt(xp) - currentLvl) * 100) +} diff --git a/src/fetchers/top-languages-fetcher.ts b/src/fetcher/index.ts similarity index 67% rename from src/fetchers/top-languages-fetcher.ts rename to src/fetcher/index.ts index cd7614d..6bec542 100644 --- a/src/fetchers/top-languages-fetcher.ts +++ b/src/fetcher/index.ts @@ -1,28 +1,7 @@ -import { codeStatsRequest, logger, CONSTANTS } from '../common/utils' +import { request, logger, CONSTANTS, getLevel, CustomError } from '../common/utils' import retryer from '../common/retryer' import languageColor from '../../themes/language-bar.json' -const fetcher = (variables: any) => { - return codeStatsRequest( - variables - ); -}; - -interface response { - user: string - total_xp: number - new_xp: number - machines: Record - languages: Record - dates: Record -} - export interface data { name: string size: number @@ -30,10 +9,23 @@ export interface data { recentSize: number } -async function fetchTopLanguages(username: string) { +export async function fetchProfile(username: string) { + if (!username) throw Error('Invalid Username') + + const response = await retryer(request, {login: username}) + + return { + username, + xp: response.data.total_xp, + recentXp: response.data.new_xp, + level: getLevel(response.data.total_xp + response.data.new_xp) + } +} + +export async function fetchTopLanguages(username: string) { if (!username) throw Error("Invalid username"); - let res: {data: response} = await retryer(fetcher, { login: username }); + let res = await retryer(request, { login: username }); let repoNodes = res.data.languages; @@ -76,5 +68,3 @@ async function fetchTopLanguages(username: string) { return topLangs as Record } - -export default fetchTopLanguages diff --git a/src/fetcher/interface.d.ts b/src/fetcher/interface.d.ts new file mode 100644 index 0000000..2a36b62 --- /dev/null +++ b/src/fetcher/interface.d.ts @@ -0,0 +1,15 @@ +export interface CodeStatsResponse { + user: string + error?: string + total_xp: number + new_xp: number + machines: Record + languages: Record + dates: Record +} diff --git a/src/getStyles.ts b/src/getStyles.ts index eed9eea..91c3dd3 100644 --- a/src/getStyles.ts +++ b/src/getStyles.ts @@ -44,49 +44,49 @@ export const getAnimations = () => { `; }; -// export const getStyles = ({ -// titleColor, -// textColor, -// iconColor, -// show_icons, -// progress, -// }) => { -// return ` -// .stat { -// font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; -// } -// .stagger { -// opacity: 0; -// animation: fadeInAnimation 0.3s ease-in-out forwards; -// } -// .rank-text { -// font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; -// animation: scaleInAnimation 0.3s ease-in-out forwards; -// } +export const getStyles = ({ + titleColor, + textColor, + iconColor, + show_icons, + progress, +}: any) => { + return ` + .stat { + font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; + } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + .rank-text { + font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; + animation: scaleInAnimation 0.3s ease-in-out forwards; + } -// .bold { font-weight: 700 } -// .icon { -// fill: ${iconColor}; -// display: ${!!show_icons ? "block" : "none"}; -// } + .bold { font-weight: 700 } + .icon { + fill: ${iconColor}; + display: ${!!show_icons ? "block" : "none"}; + } -// .rank-circle-rim { -// stroke: ${titleColor}; -// fill: none; -// stroke-width: 6; -// opacity: 0.2; -// } -// .rank-circle { -// stroke: ${titleColor}; -// stroke-dasharray: 250; -// fill: none; -// stroke-width: 6; -// stroke-linecap: round; -// opacity: 0.8; -// transform-origin: -10px 8px; -// transform: rotate(-90deg); -// animation: rankAnimation 1s forwards ease-in-out; -// } -// ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} -// `; -// }; + .rank-circle-rim { + stroke: ${titleColor}; + fill: none; + stroke-width: 6; + opacity: 0.2; + } + .rank-circle { + stroke: ${titleColor}; + stroke-dasharray: 250; + fill: none; + stroke-width: 6; + stroke-linecap: round; + opacity: 0.8; + transform-origin: -10px 8px; + transform: rotate(-90deg); + animation: rankAnimation 1s forwards ease-in-out; + } + ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} + `; +};