mirror of
https://github.com/Aviortheking/codestats-readme.git
synced 2025-08-04 18:01:58 +00:00
Added History Card
Signed-off-by: Florian Bouillon <florian.bouillon@delta-wings.net>
This commit is contained in:
282
src/cards/HistoryCard.tsx
Normal file
282
src/cards/HistoryCard.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { formatDateNumber, getColorOfLanguage } from "../common/utils"
|
||||
import Card, { CardOptions } from '../common/Card'
|
||||
import React from 'react'
|
||||
import FlexLayout from "../components/FlexLayout"
|
||||
|
||||
interface HistoryOptions extends CardOptions {
|
||||
width?: number
|
||||
height?: number
|
||||
layout?: 'horizontal'
|
||||
hide_legend?: boolean
|
||||
|
||||
// Put language in Other
|
||||
hide?: Array<string>
|
||||
language_count?: number
|
||||
}
|
||||
|
||||
export default class HistoryCard extends Card {
|
||||
|
||||
private topLanguages: Array<string>
|
||||
|
||||
public constructor(
|
||||
private username: string,
|
||||
private days: Array<{day: string, total: number, data: Array<{xp: number, language: string}>}>,
|
||||
private options: HistoryOptions
|
||||
) {
|
||||
super(options)
|
||||
|
||||
this.height = 45 + (this.days.length + 1) * 40
|
||||
this.width = options.width ?? 500
|
||||
if (options.layout === 'horizontal') {
|
||||
this.width = 45 + (this.days.length + 1) * 40 + 100
|
||||
this.height = options.height ?? 300
|
||||
}
|
||||
|
||||
const languagesToHide = options.hide || []
|
||||
|
||||
let languageCount: Array<{language: string, xp: number}> = []
|
||||
for (const day of this.days) {
|
||||
for (const data of day.data) {
|
||||
let index = languageCount.findIndex((item) => item.language === data.language)
|
||||
if (index === -1) {
|
||||
index = languageCount.push({
|
||||
language: data.language,
|
||||
xp: 0
|
||||
}) - 1
|
||||
}
|
||||
languageCount[index].xp += data.xp
|
||||
}
|
||||
}
|
||||
this.topLanguages = languageCount
|
||||
.sort((a, b) => b.xp - a.xp)
|
||||
.map((item) => item.language)
|
||||
.filter((lang) => !languagesToHide.includes(lang))
|
||||
languagesToHide.push(...this.topLanguages.splice((options.language_count || 8 )))
|
||||
if (languagesToHide.length > 0) {
|
||||
this.topLanguages.push('Other')
|
||||
}
|
||||
|
||||
for (const day of this.days) {
|
||||
const toRemove: Array<number> = []
|
||||
for (let i = 0; i < day.data.length; i++) {
|
||||
const element = day.data[i];
|
||||
if (languagesToHide.includes(element.language)) {
|
||||
const otherIndex = day.data.findIndex((el) => el.language === 'Other')
|
||||
if (otherIndex === -1) {
|
||||
day.data.push({
|
||||
language: 'Other',
|
||||
xp: element.xp
|
||||
})
|
||||
} else {
|
||||
day.data[otherIndex].xp += element.xp
|
||||
}
|
||||
toRemove.push(i)
|
||||
}
|
||||
}
|
||||
for (const index of toRemove.reverse()) {
|
||||
day.data.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this.title = 'Code History Language breakdown'
|
||||
this.css = ProgressNode.getCSS('#000')
|
||||
}
|
||||
|
||||
public render() {
|
||||
const totalTotal = this.days.reduce((prvs, crnt) => {
|
||||
if (prvs < crnt.total) {
|
||||
return crnt.total
|
||||
}
|
||||
return prvs
|
||||
}, 0)
|
||||
|
||||
const legendWidth = Math.max(this.width * 20 / 100 + 60, 150)
|
||||
const historyWidth = this.width - legendWidth
|
||||
return super.render(
|
||||
<svg x="25">
|
||||
<FlexLayout items={[(() => {
|
||||
if (this.options.layout === 'horizontal') {
|
||||
return (
|
||||
<FlexLayout items={
|
||||
this.days.reverse().map((el, index) => (
|
||||
<VerticalProgressNode
|
||||
{...el}
|
||||
totalTotal={totalTotal} height={this.height - 120} />
|
||||
))
|
||||
}
|
||||
gap={40}
|
||||
// direction="column"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<FlexLayout items={
|
||||
this.days.map((el, index) => (
|
||||
<ProgressNode
|
||||
{...el}
|
||||
totalTotal={totalTotal} width={historyWidth - 30} />
|
||||
))
|
||||
}
|
||||
gap={40}
|
||||
direction="column"
|
||||
/>
|
||||
)
|
||||
}
|
||||
})(), (
|
||||
<FlexLayout items={
|
||||
this.topLanguages.map((el, index) => (
|
||||
<>
|
||||
<rect rx="5" x="2" y="7" height="12" width="12" fill={getColorOfLanguage(el)} />
|
||||
<text x="18" y="18" className="lang-name" key={index}>{el}</text>
|
||||
</>
|
||||
))
|
||||
}
|
||||
gap={20}
|
||||
direction="column"
|
||||
/>
|
||||
)]}
|
||||
gap={this.options.layout === 'horizontal' ? this.width - 180 : historyWidth - 20}
|
||||
/>
|
||||
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ProgressNode extends React.Component<{
|
||||
day: string
|
||||
total: number
|
||||
totalTotal: number
|
||||
data: Array<{xp: number, language: string}>
|
||||
width: number
|
||||
}> {
|
||||
|
||||
public static getCSS = (textColor: string) => `
|
||||
.lang-name {
|
||||
font: 400 16px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${textColor};
|
||||
}
|
||||
.xp-txt {
|
||||
font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${textColor};
|
||||
}
|
||||
.xp-txt-invert {
|
||||
font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: white;
|
||||
}
|
||||
.subtitle {
|
||||
font: 400 14px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${textColor};
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
public render() {
|
||||
let offset = 0
|
||||
const maskId = `mask-${this.props.day}`
|
||||
return (
|
||||
<>
|
||||
<text x="2" y="15" className="lang-name">{new Date(this.props.day).toDateString().substr(4, 6)}</text>
|
||||
<svg width={this.props.width}>
|
||||
<mask id={maskId}>
|
||||
<rect x="0" y="20" width={this.calcSize(this.props.total)} height="16" fill="white" rx="5" />
|
||||
</mask>
|
||||
<rect rx="5" ry="5" x="0" y="20" width={this.props.width} height="16" fill="#ddd" />
|
||||
{this.props.data.map((el, index) => {
|
||||
const color = getColorOfLanguage(el.language)
|
||||
offset += el.xp
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
mask={`url(#${maskId})`}
|
||||
height="16"
|
||||
fill={color}
|
||||
x={this.calcSize(offset - el.xp)} y="20"
|
||||
width={`${this.calcSize(el.xp)}px`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{(() => {
|
||||
let size = this.calcSize(offset) + 6
|
||||
const txtSize = (this.props.total.toString().length + 3) * 8
|
||||
let classes = 'xp-txt'
|
||||
if (size + txtSize >= this.calcSize(this.props.totalTotal)) {
|
||||
size -= txtSize
|
||||
classes += ' xp-txt-invert'
|
||||
}
|
||||
return (
|
||||
<text x={size} y="33" className={classes}>{this.props.total} XP</text>
|
||||
)
|
||||
})()}
|
||||
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
protected calcSize(number: number) {
|
||||
return number * this.props.width / this.props.totalTotal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VerticalProgressNode extends React.Component<{
|
||||
day: string
|
||||
total: number
|
||||
totalTotal: number
|
||||
data: Array<{xp: number, language: string}>
|
||||
height: number
|
||||
}> {
|
||||
public render() {
|
||||
let offset = this.props.totalTotal
|
||||
const maskId = `mask-${this.props.day}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg x="7" y="-20" height={this.props.height + 60}>
|
||||
<mask id={maskId}>
|
||||
<rect x="0" y={25 + this.calcSize(this.props.totalTotal - this.props.total)} width="16" height={this.calcSize(this.props.total)} fill="white" rx="5" />
|
||||
</mask>
|
||||
<rect rx="5" ry="5" x="0" y="25" width="16" height={this.props.height} fill="#ddd" />
|
||||
{this.props.data.map((el, index) => {
|
||||
const color = getColorOfLanguage(el.language)
|
||||
offset -= el.xp
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
mask={`url(#${maskId})`}
|
||||
width="16"
|
||||
fill={color}
|
||||
y={25 + this.calcSize(offset)}
|
||||
x="0"
|
||||
height={`${this.calcSize(el.xp)}px`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{this.getXPTxt()}
|
||||
|
||||
</svg>
|
||||
<text x="2" y={this.props.height + 18} className="subtitle">{new Date(this.props.day).toDateString().substr(4, 3)}</text>
|
||||
<text x="5" y={this.props.height + 34} className="subtitle">{formatDateNumber(new Date(this.props.day).getDate())}</text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
protected calcSize(number: number) {
|
||||
return number * this.props.height / this.props.totalTotal
|
||||
}
|
||||
|
||||
private getXPTxt() {
|
||||
const txtLength = (this.props.total.toString().length + 3) * 13
|
||||
let position = 25 + this.calcSize(this.props.totalTotal - this.props.total) - txtLength
|
||||
let classes = 'xp-txt'
|
||||
if (position <= 28) {
|
||||
position += txtLength + 16
|
||||
classes += ' xp-txt-invert'
|
||||
}
|
||||
return (
|
||||
<text transform={`rotate(90, 4, ${position})`} letterSpacing="5" y={position} x="4" rotate="-90" className={classes}>{this.props.total} XP</text>
|
||||
)
|
||||
}
|
||||
}
|
214
src/cards/ProfileCard.tsx
Normal file
214
src/cards/ProfileCard.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { kFormatter, encodeHTML, getProgress, getLevel, parseBoolean, calculateCircleProgress, getColor } from '../common/utils'
|
||||
import icons from '../common/icons'
|
||||
import Card, { CardOptions } from '../common/Card'
|
||||
import React from 'react'
|
||||
import FlexLayout from '../components/FlexLayout'
|
||||
|
||||
interface ProfileCardOptions extends CardOptions {
|
||||
hide?: Array<string>
|
||||
show_icons?: boolean
|
||||
hide_rank?: boolean
|
||||
line_height?: number
|
||||
icon_color?: string
|
||||
text_color?: string
|
||||
}
|
||||
|
||||
export default class ProfileCard extends Card {
|
||||
private stats: Record<string, {
|
||||
icon: JSX.Element
|
||||
label: string
|
||||
value: number
|
||||
}>
|
||||
|
||||
private defaults = {
|
||||
line_height: 25,
|
||||
hide: []
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private username: string,
|
||||
private xp: number,
|
||||
private recentXp: number,
|
||||
private options: ProfileCardOptions
|
||||
) {
|
||||
super(
|
||||
options
|
||||
)
|
||||
|
||||
// This Element
|
||||
this.stats = {
|
||||
xp: {
|
||||
icon: icons.star,
|
||||
label: "XP",
|
||||
value: xp,
|
||||
},
|
||||
recent_xp: {
|
||||
icon: icons.commits,
|
||||
label: 'Recent xp',
|
||||
value: this.recentXp,
|
||||
},
|
||||
}
|
||||
|
||||
// Card Settings
|
||||
this.width = 495
|
||||
this.height = Math.max(
|
||||
45 + (Object.keys(this.stats).length + 1) * (this.options.line_height || this.defaults.line_height),
|
||||
options.hide_rank ? 0 : 120
|
||||
)
|
||||
|
||||
this.title = `${encodeHTML(this.username)}${
|
||||
["x", "s"].includes(this.username.slice(-1)) ? "" : "s"
|
||||
} Code::Stats Profile`
|
||||
|
||||
const textColor = getColor('text_color', options.text_color, options.theme)
|
||||
const iconColor = getColor('icon_color', options.icon_color, options.theme)
|
||||
|
||||
if (!this.options.hide_rank) {
|
||||
this.css += RankCircle.getCSS(textColor, iconColor, getProgress(xp))
|
||||
}
|
||||
if ((this.options.hide || []) < Object.keys(this.stats)) {
|
||||
this.css += TextNode.getCSS(textColor, !!this.options.show_icons ? iconColor : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return super.render(
|
||||
<>
|
||||
<RankCircle xp={this.options.hide_rank ? undefined : this.xp} />
|
||||
<svg x="0" y="0">
|
||||
<FlexLayout
|
||||
items={Object.keys(this.stats).filter((item) => !(this.options.hide || []).includes(item)).map((el, index) => {
|
||||
const item = this.stats[el]
|
||||
return (
|
||||
<TextNode
|
||||
{...item}
|
||||
icon={!!this.options.show_icons ? item.icon : undefined}
|
||||
key={index}
|
||||
index={index}
|
||||
|
||||
/>
|
||||
)
|
||||
})}
|
||||
gap={this.options.line_height || this.defaults.line_height}
|
||||
direction="column"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class RankCircle extends React.Component<{
|
||||
xp?: number
|
||||
}> {
|
||||
|
||||
public static getCSS = (textColor: string, titleColor: string, progress: number) => `
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: ${calculateCircleProgress(0)};
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: ${calculateCircleProgress(progress)};
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
public render () {
|
||||
if (!this.props.xp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<g data-testid="rank-circle"
|
||||
transform="translate(400, 0)">
|
||||
<circle className="rank-circle-rim" cx="-10" cy="8" r="40" />
|
||||
<circle className="rank-circle" cx="-10" cy="8" r="40" />
|
||||
<g className="rank-text">
|
||||
<text
|
||||
x="-4"
|
||||
y="0"
|
||||
alignmentBaseline="central"
|
||||
dominantBaseline="central"
|
||||
textAnchor="middle"
|
||||
>
|
||||
lv {getLevel(this.props.xp)}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class TextNode extends React.Component<{
|
||||
icon?: JSX.Element
|
||||
label: string
|
||||
value: number
|
||||
index: number
|
||||
}> {
|
||||
|
||||
public static getCSS = (textColor: string, iconColor?: string) => `
|
||||
${
|
||||
iconColor ? `.icon {
|
||||
fill: ${iconColor};
|
||||
// display: block;
|
||||
}` : ''
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||
}
|
||||
.bold {
|
||||
font-weight: 700
|
||||
}
|
||||
`
|
||||
|
||||
public render() {
|
||||
|
||||
const delay = (this.props.index + 3 * 150)
|
||||
|
||||
// Icon prefixing line
|
||||
const icon = this.props.icon ? (
|
||||
<svg data-testid="icon" className="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
{this.props.icon}
|
||||
</svg>
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<g className="stagger" data-index={this.props.index} style={{animationDelay: `${delay}ms`}} transform="translate(25, 0)">
|
||||
{icon}
|
||||
<text className="stat bold" x={this.props.icon ? 25 : undefined} y="12.5">{this.props.label}:</text>
|
||||
<text
|
||||
className="stat"
|
||||
x={this.props.icon ? 120 : 100}
|
||||
y="12.5"
|
||||
>{kFormatter(this.props.value)}</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
}
|
179
src/cards/TopLanguagesCard.tsx
Normal file
179
src/cards/TopLanguagesCard.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { getColor, getProgress, trunc } from "../common/utils"
|
||||
import Card, { CardOptions } from '../common/Card'
|
||||
import React from 'react'
|
||||
import FlexLayout from "../components/FlexLayout";
|
||||
import { TopLanguage } from "../interfaces";
|
||||
|
||||
interface TopLanguagesOptions extends CardOptions {
|
||||
hide?: Array<string>
|
||||
language_count?: number
|
||||
card_width?: number
|
||||
layout?: string
|
||||
text_color?: string
|
||||
}
|
||||
|
||||
export default class TopLanguagesCard extends Card {
|
||||
|
||||
public constructor(
|
||||
private username: string,
|
||||
private langs: Array<TopLanguage>,
|
||||
private options: TopLanguagesOptions
|
||||
) {
|
||||
super(options)
|
||||
|
||||
|
||||
this.langs = this.langs
|
||||
.filter((item) => !(options.hide || []).includes(item.name))
|
||||
.slice(0, options.language_count || 5)
|
||||
|
||||
|
||||
this.height = 45 + (this.langs.length + 1) * 40
|
||||
this.width = 300
|
||||
if (options.card_width && !isNaN(options.card_width)) {
|
||||
this.width = options.card_width
|
||||
}
|
||||
|
||||
const textColor = getColor('text_color', options.text_color, options.theme)
|
||||
this.title = "Most Used Languages"
|
||||
this.css = CompactTextNode.getCSS(textColor as string)
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.options.layout === 'compact') {
|
||||
this.width = this.width + 50
|
||||
this.height = 90 + Math.round(this.langs.length / 2) * 25
|
||||
return super.render(
|
||||
<svg x="25">
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width={this.width - 50} height="8" fill="white" rx="5" />
|
||||
</mask>
|
||||
<CompactProgressBar langs={this.langs} parentWidth={this.width} />
|
||||
{this.langs.map((el, index) => (
|
||||
<CompactTextNode key={index} index={index} lang={el} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
} else {
|
||||
return super.render(
|
||||
<svg x="25">
|
||||
<FlexLayout items={
|
||||
this.langs.map((el, index) => (
|
||||
<ProgressNode lang={el} parentWidth={this.width} />
|
||||
))
|
||||
}
|
||||
gap={40}
|
||||
direction="column"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CompactProgressBar extends React.Component<{
|
||||
langs: Array<TopLanguage>
|
||||
parentWidth: number
|
||||
}> {
|
||||
public render() {
|
||||
|
||||
let offset = 0
|
||||
const totalSize = this.props.langs.reduce((acc, curr) => acc + curr.xp, 0)
|
||||
|
||||
return this.props.langs.map((lang, index) => {
|
||||
const percent = trunc((lang.xp / totalSize) * (this.props.parentWidth - 50), 2)
|
||||
const progress = percent < 10 ? percent + 10 : percent
|
||||
|
||||
const output = (
|
||||
<rect
|
||||
key={index}
|
||||
mask="url(#rect-mask)"
|
||||
data-testid="lang-progress"
|
||||
x={offset}
|
||||
y="0"
|
||||
width={progress}
|
||||
height="8"
|
||||
fill={lang.color || "#858585"}
|
||||
/>
|
||||
)
|
||||
|
||||
offset += percent
|
||||
return output
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class CompactTextNode extends React.Component<{
|
||||
index: number
|
||||
lang: TopLanguage
|
||||
}> {
|
||||
|
||||
public static getCSS = (textColor: string) => `
|
||||
.lang-name {
|
||||
font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${textColor};
|
||||
}
|
||||
`
|
||||
|
||||
public render() {
|
||||
const index = this.props.index
|
||||
|
||||
let x = 0
|
||||
let y = 12.5 * index + 25
|
||||
if (index % 2 !== 0) {
|
||||
x = 150
|
||||
y = 12.5 + 12.5 * index
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x}, ${y})`}>
|
||||
<circle cx="5" cy="6" r="5" fill={this.props.lang.color} />
|
||||
<text data-testid="lang-name" x="15" y="10" className='lang-name'>
|
||||
{this.props.lang.name} {getProgress(this.props.lang.xp)}%
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ProgressNode extends React.Component<{
|
||||
lang: TopLanguage
|
||||
parentWidth: number
|
||||
}> {
|
||||
|
||||
private paddingRight = 60
|
||||
|
||||
|
||||
public render() {
|
||||
const width = this.props.parentWidth - this.paddingRight
|
||||
const progress1 = getProgress(this.props.lang.xp)
|
||||
const progress2 = getProgress(this.props.lang.xp - this.props.lang.recentXp)
|
||||
|
||||
return (
|
||||
<>
|
||||
<text data-testid="lang-name" x="2" y="15" className="lang-name">{this.props.lang.name} {progress1}% {this.props.lang.recentXp >= 1 ? ' + ' + (trunc(progress1 - progress2, 2)) + '%' : ''}</text>
|
||||
<svg width={width}>
|
||||
<rect rx="5" ry="5" x="0" y="25" width={width} height="8" fill="#ddd" />
|
||||
{progress1 !== progress2 && (
|
||||
<rect
|
||||
height="8"
|
||||
fill="#f2b866"
|
||||
rx="5" ry="5" x="1" y="25"
|
||||
width={`${progress1}%`}
|
||||
/>
|
||||
)}
|
||||
{progress1 >= progress2 && (
|
||||
<rect
|
||||
height="8"
|
||||
fill={this.props.lang.color}
|
||||
rx="5" ry="5" x="0" y="25"
|
||||
data-testid="lang-progress"
|
||||
width={`${progress2}%`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,185 +0,0 @@
|
||||
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
|
||||
? (
|
||||
<svg data-testid="icon" className="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
{icon}
|
||||
</svg>
|
||||
)
|
||||
: undefined
|
||||
return (
|
||||
<g className="stagger" style={{animationDelay: `${staggerDelay}ms`}} transform="translate(25, 0)">
|
||||
{iconSvg}
|
||||
<text className="stat bold" x={showIcons ? 25 : undefined} y="12.5">{label}:</text>
|
||||
<text
|
||||
className="stat"
|
||||
x={showIcons ? 120 : 100}
|
||||
y="12.5"
|
||||
>{kValue}</text>
|
||||
</g>
|
||||
)
|
||||
};
|
||||
|
||||
interface Options {
|
||||
hide?: Array<string>
|
||||
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
|
||||
: (
|
||||
<g data-testid="rank-circle"
|
||||
transform={`translate(400, 0)`}>
|
||||
<circle className="rank-circle-rim" cx="-10" cy="8" r="40" />
|
||||
<circle className="rank-circle" cx="-10" cy="8" r="40" />
|
||||
<g className="rank-text">
|
||||
<text
|
||||
x="-4"
|
||||
y="0"
|
||||
alignmentBaseline="central"
|
||||
dominantBaseline="central"
|
||||
textAnchor="middle"
|
||||
>
|
||||
lv {level}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
|
||||
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}
|
||||
<svg x="0" y="0">
|
||||
{FlexLayout({
|
||||
items: statItems,
|
||||
gap: lheight,
|
||||
direction: "column",
|
||||
})}
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -1,242 +0,0 @@
|
||||
import { getCardColors, FlexLayout, clampValue } from "../common/utils"
|
||||
import Card from '../common/Card'
|
||||
import { data } from "../fetcher";
|
||||
import React from 'react'
|
||||
import themes from "../../themes";
|
||||
|
||||
export interface parsedQuery {
|
||||
hide?: Array<string>
|
||||
hide_title?: boolean
|
||||
hide_border?: boolean
|
||||
card_width?: number
|
||||
title_color?: string
|
||||
text_color?: string
|
||||
bg_color?: string
|
||||
language_count?: number
|
||||
show_level?: string
|
||||
theme?: keyof typeof themes
|
||||
cache_seconds?: string
|
||||
layout?: string
|
||||
}
|
||||
|
||||
const createProgressNode = ({ width, color, name, progress, progress2 }: {width: number ,color: string, name: string, progress: number, progress2:number}) => {
|
||||
const paddingRight = 60;
|
||||
const progressWidth = width - paddingRight;
|
||||
const progressPercentage = clampValue(progress, 2, 100);
|
||||
const progress2Percentage = clampValue(progress2, 2, 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text data-testid="lang-name" x="2" y="15" className="lang-name">{name} {progress}%{progress2 > progress ? ` + ${progress2 - progress}%` : ''}</text>
|
||||
<svg width={progressWidth}>
|
||||
<rect rx="5" ry="5" x="0" y="25" width={progressWidth} height="8" fill="#ddd" />
|
||||
<rect
|
||||
height="8"
|
||||
fill="#f2b866"
|
||||
rx="5" ry="5" x="1" y="25"
|
||||
width={`calc(${progress2Percentage}% - 1px)`}
|
||||
/>
|
||||
<rect
|
||||
height="8"
|
||||
fill={color}
|
||||
rx="5" ry="5" x="0" y="25"
|
||||
data-testid="lang-progress"
|
||||
width={`${progressPercentage}%`}
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
const createCompactLangNode = ({ lang, totalSize, x, y }: {lang: data, totalSize: number, x: number, y: number}) => {
|
||||
const percentage = ((lang.size / totalSize) * 100).toFixed(2);
|
||||
const color = lang.color || "#858585";
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x}, ${y})`}>
|
||||
<circle cx="5" cy="6" r="5" fill={color} />
|
||||
<text data-testid="lang-name" x="15" y="10" className='lang-name'>
|
||||
{lang.name} {percentage}%
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
};
|
||||
|
||||
const createLanguageTextNode = ({ langs, totalSize, x, y }: { langs: Array<data>, totalSize: number, x: number, y: number}) => {
|
||||
return langs.map((lang, index) => {
|
||||
if (index % 2 === 0) {
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
x,
|
||||
y: 12.5 * index + y,
|
||||
totalSize
|
||||
});
|
||||
}
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
x: 150,
|
||||
y: 12.5 + 12.5 * index,
|
||||
totalSize
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const lowercaseTrim = (name: string) => name.toLowerCase().trim();
|
||||
|
||||
const renderTopLanguages = (topLangs: Record<string, data>, options: parsedQuery = {}) => {
|
||||
const {
|
||||
hide_title,
|
||||
hide_border,
|
||||
card_width,
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
hide,
|
||||
language_count,
|
||||
theme,
|
||||
layout,
|
||||
} = options;
|
||||
|
||||
let langs = Object.values(topLangs);
|
||||
let langsToHide: Record<string, boolean> = {};
|
||||
|
||||
// populate langsToHide map for quick lookup
|
||||
// while filtering out
|
||||
if (hide) {
|
||||
hide.forEach((langName) => {
|
||||
langsToHide[lowercaseTrim(langName)] = true;
|
||||
});
|
||||
}
|
||||
|
||||
// filter out langauges to be hidden
|
||||
langs = langs
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.filter((lang) => {
|
||||
return !langsToHide[lowercaseTrim(lang.name)];
|
||||
})
|
||||
.slice(0, language_count || 5);
|
||||
|
||||
const totalLanguageSize = langs.reduce((acc, curr) => {
|
||||
return acc + curr.size;
|
||||
}, 0);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, bgColor } = getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
let width = typeof card_width !== 'number' ? 300 : isNaN(card_width) ? 300 : card_width;
|
||||
let height = 45 + (langs.length + 1) * 40;
|
||||
|
||||
let finalLayout: JSX.Element | Array<JSX.Element>;
|
||||
|
||||
// RENDER COMPACT LAYOUT
|
||||
if (layout === "compact") {
|
||||
width = width + 50;
|
||||
height = 30 + (langs.length / 2 + 1) * 40;
|
||||
|
||||
// progressOffset holds the previous language's width and used to offset the next language
|
||||
// so that we can stack them one after another, like this: [--][----][---]
|
||||
let progressOffset = 0;
|
||||
const compactProgressBar = langs
|
||||
.map((lang, index) => {
|
||||
const percentage = parseFloat((
|
||||
(lang.size / totalLanguageSize) *
|
||||
(width - 50)
|
||||
).toFixed(2));
|
||||
|
||||
const progress =
|
||||
percentage < 10 ? percentage + 10 : percentage;
|
||||
|
||||
const output = (
|
||||
<rect
|
||||
key={index}
|
||||
mask="url(#rect-mask)"
|
||||
data-testid="lang-progress"
|
||||
x={progressOffset}
|
||||
y="0"
|
||||
width={progress}
|
||||
height="8"
|
||||
fill={lang.color || "#858585"}
|
||||
/>
|
||||
)
|
||||
progressOffset += percentage;
|
||||
return output;
|
||||
})
|
||||
|
||||
finalLayout = (
|
||||
<>
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width={
|
||||
width - 50
|
||||
} height="8" fill="white" rx="5" />
|
||||
</mask>
|
||||
{compactProgressBar}
|
||||
{createLanguageTextNode({
|
||||
x: 0,
|
||||
y: 25,
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
finalLayout = (
|
||||
<>
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width={width - 50} height="8" fill="white" rx="5" />
|
||||
</mask>
|
||||
{compactProgressBar}
|
||||
{createLanguageTextNode({
|
||||
x: 0,
|
||||
y: 25,
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
finalLayout = FlexLayout({
|
||||
items: langs.map((lang) => {
|
||||
return createProgressNode({
|
||||
width: width,
|
||||
name: lang.name,
|
||||
color: lang.color || "#858585",
|
||||
progress: parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
|
||||
progress2: parseFloat(((lang.recentSize / totalLanguageSize) * 100).toFixed(2)),
|
||||
});
|
||||
}),
|
||||
gap: 40,
|
||||
direction: "column",
|
||||
})
|
||||
}
|
||||
|
||||
const card = new Card(
|
||||
width,
|
||||
height,
|
||||
{
|
||||
titleColor,
|
||||
textColor,
|
||||
bgColor,
|
||||
},
|
||||
"Most Used Languages",
|
||||
)
|
||||
|
||||
card.disableAnimations();
|
||||
card.setHideBorder(hide_border || false);
|
||||
card.setHideTitle(hide_title || false);
|
||||
card.setCSS(`
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
`);
|
||||
|
||||
return card.render(
|
||||
<svg data-testid="lang-items" x="25">
|
||||
{finalLayout}
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
export default renderTopLanguages
|
@@ -1,7 +1,16 @@
|
||||
import React from 'react'
|
||||
import themes from '../../themes/themes.json'
|
||||
import FlexLayout from '../components/FlexLayout'
|
||||
|
||||
import { FlexLayout } from './utils'
|
||||
import { getAnimations } from '../getStyles'
|
||||
import { getColor, parseBoolean } from './utils'
|
||||
|
||||
export interface CardOptions {
|
||||
title_color?: string
|
||||
bg_color?: string
|
||||
hide_border?: boolean
|
||||
hide_title?: boolean
|
||||
theme?: keyof typeof themes
|
||||
}
|
||||
|
||||
export default class Card {
|
||||
public hideBorder = false
|
||||
@@ -10,45 +19,32 @@ export default class Card {
|
||||
public paddingX = 25
|
||||
public paddingY = 35
|
||||
public animations = true
|
||||
public height = 100
|
||||
public width = 100
|
||||
public title = ''
|
||||
public colors: {
|
||||
titleColor?: string | Array<string>,
|
||||
bgColor?: string | Array<string>
|
||||
} = {}
|
||||
public titlePrefix?: JSX.Element
|
||||
|
||||
constructor(
|
||||
public width = 100,
|
||||
public height = 100,
|
||||
public colors: {titleColor?: string | Array<string>, textColor?: string | Array<string>, bgColor?: string | Array<string>, iconColor?: string | Array<string>} = {},
|
||||
public title = "",
|
||||
public titlePrefixIcon?: string
|
||||
) {}
|
||||
|
||||
disableAnimations() {
|
||||
this.animations = false;
|
||||
}
|
||||
|
||||
setCSS(value: string) {
|
||||
this.css = value;
|
||||
}
|
||||
|
||||
setHideBorder(value: boolean) {
|
||||
this.hideBorder = value;
|
||||
}
|
||||
|
||||
setHideTitle(value: boolean) {
|
||||
this.hideTitle = value;
|
||||
if (value) {
|
||||
this.height -= 30;
|
||||
constructor(options?: CardOptions) {
|
||||
if (options) {
|
||||
this.hideBorder = parseBoolean(options.hide_border)
|
||||
this.hideTitle = parseBoolean(options.hide_title)
|
||||
this.colors = {
|
||||
titleColor: getColor('title_color', options.title_color, options.theme),
|
||||
bgColor: getColor('bg_color', options.bg_color, options.theme),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(text: string) {
|
||||
this.title = text;
|
||||
}
|
||||
|
||||
renderTitle() {
|
||||
const titleText = (
|
||||
<text
|
||||
x="0"
|
||||
y="0"
|
||||
className="header"
|
||||
data-testid="header"
|
||||
>{this.title}</text>
|
||||
)
|
||||
|
||||
@@ -63,18 +59,17 @@ export default class Card {
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
${this.titlePrefixIcon}
|
||||
{this.titlePrefix}
|
||||
</svg>
|
||||
)
|
||||
return (
|
||||
<g
|
||||
data-testid="card-title"
|
||||
transform={`translate(${this.paddingX}, ${this.paddingY})`}
|
||||
>
|
||||
{FlexLayout({
|
||||
items: [this.titlePrefixIcon && prefixIcon, titleText],
|
||||
gap: 25,
|
||||
})}
|
||||
<FlexLayout
|
||||
items={[this.titlePrefix && prefixIcon, titleText]}
|
||||
gap={25}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -104,7 +99,7 @@ export default class Card {
|
||||
return (
|
||||
<svg
|
||||
width={this.width}
|
||||
height={this.height}
|
||||
height={this.height - (this.hideTitle ? 30 : 0)}
|
||||
viewBox={`0 0 ${this.width} ${this.height}`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -117,17 +112,28 @@ export default class Card {
|
||||
}
|
||||
${this.css}
|
||||
|
||||
${
|
||||
process.env.NODE_ENV === "test" || !this.animations
|
||||
? ""
|
||||
: getAnimations()
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{this.renderGradient()}
|
||||
|
||||
<rect
|
||||
data-testid="card-bg"
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
rx="4.5"
|
||||
@@ -145,7 +151,6 @@ export default class Card {
|
||||
{this.hideTitle ? "" : this.renderTitle()}
|
||||
|
||||
<g
|
||||
data-testid="main-card-body"
|
||||
transform={`translate(0, ${
|
||||
this.hideTitle ? this.paddingX : this.paddingY + 20
|
||||
})`}
|
||||
|
@@ -1,3 +0,0 @@
|
||||
const blacklist = ["renovate-bot", "technote-space", "sw-yx"];
|
||||
|
||||
export default blacklist
|
@@ -1,19 +1,20 @@
|
||||
import { CustomError, request } from './utils'
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { CustomError } from './utils'
|
||||
import { CodeStatsResponse } from '../interfaces';
|
||||
|
||||
const retryer = async <T = AxiosPromise<{error: string}>>(fetcher: (variables: {login: string}) => T, variables: {login: string}, retries = 0): Promise<T> => {
|
||||
export default async function retryer<T = Promise<CodeStatsResponse>>(
|
||||
fetcher: (username: string) => T,
|
||||
data: string,
|
||||
retries = 0,
|
||||
err?: any
|
||||
): Promise<T> {
|
||||
if (retries > 7) {
|
||||
throw new CustomError("Maximum retries exceeded", 'MAX_RETRY');
|
||||
throw new CustomError("Maximum retries exceeded" + err, 'MAX_RETRY')
|
||||
}
|
||||
try {
|
||||
let response = await fetcher(
|
||||
variables
|
||||
return await fetcher(
|
||||
data
|
||||
)
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
return retryer(fetcher, variables, 1 + retries);
|
||||
return retryer(fetcher, data, ++retries, err)
|
||||
}
|
||||
};
|
||||
|
||||
export default retryer
|
||||
}
|
||||
|
208
src/common/utils.ts
Normal file
208
src/common/utils.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import fetch from 'node-fetch'
|
||||
import { Response } from 'express'
|
||||
import wrap from 'word-wrap'
|
||||
import themes from '../../themes/themes.json'
|
||||
import { CodeStatsResponse } from '../interfaces';
|
||||
import languageColor from '../../themes/language-bar.json'
|
||||
|
||||
/**
|
||||
* Encode a string to escape HTML
|
||||
*
|
||||
* https://stackoverflow.com/a/48073476/10629172
|
||||
* @param str the string to encode
|
||||
*/
|
||||
export function encodeHTML(str: string) {
|
||||
return str
|
||||
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
|
||||
return "&#" + i.charCodeAt(0) + ";";
|
||||
})
|
||||
.replace(/\u0008/gim, "");
|
||||
}
|
||||
|
||||
export const kFormatter = (num: number) =>
|
||||
Math.abs(num) > 999 ?
|
||||
trunc(num / 1000) + 'k' :
|
||||
num
|
||||
|
||||
/**
|
||||
* Transform the `value` into a Boolean
|
||||
* @param value the value to transform
|
||||
*/
|
||||
export function parseBoolean(value: boolean | string | undefined) {
|
||||
if (value === "true" || value === true) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseArray(str: string | undefined) {
|
||||
if (!str) return [];
|
||||
return str.split(",");
|
||||
}
|
||||
|
||||
export function clampValue(number: number, min: number, max?: number) {
|
||||
return Math.max(min, max ? Math.min(number, max) : number);
|
||||
}
|
||||
|
||||
export async function request(username: string): Promise<CodeStatsResponse> {
|
||||
const resp = await fetch(
|
||||
`https://codestats.net/api/users/${username}`
|
||||
);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function profileGraphRequest<T>(body: string): Promise<T> {
|
||||
const resp = await fetch(
|
||||
'https://codestats.net/profile-graph',
|
||||
{
|
||||
body,
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export function getColor(color: keyof typeof themes.default, replacementColor?: string, theme: keyof typeof themes = 'default') {
|
||||
return '#' + (replacementColor ? replacementColor : themes[theme][color])
|
||||
}
|
||||
|
||||
export function wrapTextMultiline(text: string, width = 60, maxLines = 3) {
|
||||
const wrapped = wrap(encodeHTML(text), { width })
|
||||
.split("\n") // Split wrapped lines to get an array of lines
|
||||
.map((line) => line.trim()); // Remove leading and trailing whitespace of each line
|
||||
|
||||
const lines = wrapped.slice(0, maxLines); // Only consider maxLines lines
|
||||
|
||||
// Add "..." to the last line if the text exceeds maxLines
|
||||
if (wrapped.length > maxLines) {
|
||||
lines[maxLines - 1] += "...";
|
||||
}
|
||||
|
||||
// Remove empty lines if text fits in less than maxLines lines
|
||||
const multiLineText = lines.filter(Boolean);
|
||||
return multiLineText;
|
||||
}
|
||||
|
||||
export const CONSTANTS = {
|
||||
THIRTY_MINUTES: 1800,
|
||||
TWO_HOURS: 7200,
|
||||
FOUR_HOURS: 14400,
|
||||
ONE_DAY: 86400,
|
||||
LEVEL_FACTOR: 0.025,
|
||||
};
|
||||
|
||||
export const SECONDARY_ERROR_MESSAGES = {
|
||||
MAX_RETRY: "Make sur your profile is not private"
|
||||
};
|
||||
|
||||
export class CustomError extends Error {
|
||||
public secondaryMessage: string
|
||||
|
||||
constructor(message: string, public type: keyof typeof SECONDARY_ERROR_MESSAGES) {
|
||||
super(message);
|
||||
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || "adsad";
|
||||
}
|
||||
|
||||
static MAX_RETRY = "MAX_RETRY";
|
||||
static USER_NOT_FOUND = "USER_NOT_FOUND";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the level depending on the xp
|
||||
*
|
||||
* https://codestats.net/api-docs
|
||||
* @param xp the xp count
|
||||
*/
|
||||
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 trunc((CONSTANTS.LEVEL_FACTOR * Math.sqrt(xp) - currentLvl) * 100, 2)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Truncate a number without moving it to a string and reparsing it
|
||||
*
|
||||
* https://stackoverflow.com/a/29494612/7335674
|
||||
* @param number the number to truncate
|
||||
* @param digits the number of digits after the dot
|
||||
*/
|
||||
export function trunc(number: number, digits: number = 0) {
|
||||
const pow = Math.pow(10, digits)
|
||||
return Math.round(number * pow) / pow
|
||||
}
|
||||
|
||||
export function parseNumber(number: string | number | undefined): number | undefined {
|
||||
if (typeof number === 'undefined' || typeof number === 'number') {
|
||||
return number
|
||||
}
|
||||
const n = parseFloat(number)
|
||||
if (isNaN(n)) {
|
||||
return undefined
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
export function calculateCircleProgress(percent: number) {
|
||||
let radius = 40;
|
||||
let c = Math.PI * radius * 2
|
||||
|
||||
percent = clampValue(percent, 0, 100)
|
||||
|
||||
let percentage = ((100 - percent) / 100) * c
|
||||
return percentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the response
|
||||
* @param res the response object
|
||||
*/
|
||||
export function prepareResponse(res: Response) {
|
||||
res.setHeader("Content-Type", "image/svg+xml")
|
||||
}
|
||||
|
||||
/**
|
||||
* set the cache in the response
|
||||
* @param res the Response object
|
||||
* @param cache The cache time in seconds
|
||||
*/
|
||||
export function setCache(res: Response, cache: number) {
|
||||
if (isNaN(cache)) {
|
||||
cache = CONSTANTS.THIRTY_MINUTES
|
||||
}
|
||||
const clampedCache = clampValue(cache, CONSTANTS.THIRTY_MINUTES, CONSTANTS.ONE_DAY)
|
||||
|
||||
res.setHeader('Cache-Control', `public, stale-while-revalidate, max-age=${clampedCache} s-maxage=${clampedCache}`)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function lowercaseTrim(str: string) {
|
||||
return str.toLowerCase().trim()
|
||||
}
|
||||
|
||||
|
||||
export function formatDateNumber(number: number): string {
|
||||
if (number < 10) {
|
||||
return '0' + number
|
||||
}
|
||||
return number + ''
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${formatDateNumber(date.getMonth() + 1)}-${formatDateNumber(date.getDate())}`
|
||||
}
|
||||
|
||||
|
||||
export function getColorOfLanguage(name: string): string {
|
||||
return name in languageColor ? languageColor[name as keyof typeof languageColor].color || '#000' : '#000'
|
||||
}
|
@@ -1,203 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
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 (
|
||||
<svg width="495" height="120" viewBox="0 0 495 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>{`
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2F80ED }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #252525 }
|
||||
.gray { fill: #858585 }
|
||||
`}</style>
|
||||
<rect x="0.5" y="0.5" width="494" height="99%" rx="4.5" fill="#FFFEFE" stroke="#E4E2E2"/>
|
||||
<text x="25" y="45" className="text">Something went wrong! file an issue at https://dze.io/3nL29</text>
|
||||
<text data-testid="message" x="25" y="55" className="text small">
|
||||
<tspan x="25" dy="18">{encodeHTML(message)}</tspan>
|
||||
<tspan x="25" dy="18" className="gray">{secondaryMessage}</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/a/48073476/10629172
|
||||
export function encodeHTML(str: string) {
|
||||
return str
|
||||
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
|
||||
return "&#" + i.charCodeAt(0) + ";";
|
||||
})
|
||||
.replace(/\u0008/gim, "");
|
||||
}
|
||||
|
||||
export function kFormatter(num: number) {
|
||||
return Math.abs(num) > 999
|
||||
? Math.sign(num) * parseInt((Math.abs(num) / 1000).toFixed(1)) + "k"
|
||||
: Math.sign(num) * Math.abs(num);
|
||||
}
|
||||
|
||||
export function isValidHexColor(hexColor: string) {
|
||||
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);
|
||||
}
|
||||
|
||||
export function parseBoolean(value: string | undefined) {
|
||||
if (value === "true") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseArray(str: string | undefined) {
|
||||
if (!str) return [];
|
||||
return str.split(",");
|
||||
}
|
||||
|
||||
export function clampValue(number: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(number, max));
|
||||
}
|
||||
|
||||
export function isValidGradient(colors: Array<string>) {
|
||||
return isValidHexColor(colors[1]) && isValidHexColor(colors[2]);
|
||||
}
|
||||
|
||||
export function fallbackColor(color: string, fallbackColor: Array<string>| string) {
|
||||
let colors = color.split(",");
|
||||
let gradient = null;
|
||||
|
||||
if (colors.length > 1 && isValidGradient(colors)) {
|
||||
gradient = colors;
|
||||
}
|
||||
|
||||
return (
|
||||
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
|
||||
fallbackColor
|
||||
);
|
||||
}
|
||||
|
||||
export function request(data: {login: string}): AxiosPromise<CodeStatsResponse> {
|
||||
return axios({
|
||||
url: "https://codestats.net/api/users/" + data.login,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String[]} items
|
||||
* @param {Number} gap
|
||||
* @param {string} direction
|
||||
*
|
||||
* @description
|
||||
* Auto layout utility, allows us to layout things
|
||||
* vertically or horizontally with proper gaping
|
||||
*/
|
||||
export function FlexLayout({ items, gap, direction }: {items: Array<JSX.Element | string | undefined>, gap: number, direction?: string}) {
|
||||
// filter() for filtering out empty strings
|
||||
return items.filter(Boolean).map((item, i) => {
|
||||
let transform = `translate(${gap * i}, 0)`;
|
||||
if (direction === "column") {
|
||||
transform = `translate(0, ${gap * i})`;
|
||||
}
|
||||
return (
|
||||
<g key={i} transform={transform}>{item}</g>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
export function getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color,
|
||||
bg_color,
|
||||
theme,
|
||||
fallbackTheme = "default",
|
||||
}: {title_color?: string, text_color?: string, icon_color?: string, bg_color?: string, theme?: keyof typeof themes, fallbackTheme?: keyof typeof themes}) {
|
||||
const defaultTheme = themes[fallbackTheme];
|
||||
const selectedTheme = themes[theme as 'default'] || defaultTheme;
|
||||
|
||||
// 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
|
||||
);
|
||||
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
|
||||
);
|
||||
|
||||
return { titleColor, iconColor, textColor, bgColor };
|
||||
}
|
||||
|
||||
export function wrapTextMultiline(text: string, width = 60, maxLines = 3) {
|
||||
const wrapped = wrap(encodeHTML(text), { width })
|
||||
.split("\n") // Split wrapped lines to get an array of lines
|
||||
.map((line) => line.trim()); // Remove leading and trailing whitespace of each line
|
||||
|
||||
const lines = wrapped.slice(0, maxLines); // Only consider maxLines lines
|
||||
|
||||
// Add "..." to the last line if the text exceeds maxLines
|
||||
if (wrapped.length > maxLines) {
|
||||
lines[maxLines - 1] += "...";
|
||||
}
|
||||
|
||||
// Remove empty lines if text fits in less than maxLines lines
|
||||
const multiLineText = lines.filter(Boolean);
|
||||
return multiLineText;
|
||||
}
|
||||
|
||||
export const noop = () => {};
|
||||
// return console instance based on the environment
|
||||
export const logger =
|
||||
process.env.NODE_ENV !== "test" ? console : { log: noop, error: noop };
|
||||
|
||||
export const CONSTANTS = {
|
||||
THIRTY_MINUTES: 1800,
|
||||
TWO_HOURS: 7200,
|
||||
FOUR_HOURS: 14400,
|
||||
ONE_DAY: 86400,
|
||||
LEVEL_FACTOR: 0.025,
|
||||
};
|
||||
|
||||
export const SECONDARY_ERROR_MESSAGES = {
|
||||
MAX_RETRY: "Make sur your profile is not private"
|
||||
};
|
||||
|
||||
export class CustomError extends Error {
|
||||
public secondaryMessage: string
|
||||
|
||||
constructor(message: string, public type: keyof typeof SECONDARY_ERROR_MESSAGES) {
|
||||
super(message);
|
||||
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || "adsad";
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
24
src/components/Error.tsx
Normal file
24
src/components/Error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { encodeHTML } from '../common/utils'
|
||||
|
||||
export default class Error extends React.Component<{
|
||||
message: string
|
||||
secondaryMessage?: string
|
||||
}> {
|
||||
|
||||
public render = () => (
|
||||
<svg width="495" height="120" viewBox="0 0 495 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>{`
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2F80ED }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #252525 }
|
||||
.gray { fill: #858585 }
|
||||
`}</style>
|
||||
<rect x="0.5" y="0.5" width="494" height="99%" rx="4.5" fill="#FFFEFE" stroke="#E4E2E2"/>
|
||||
<text x="25" y="45" className="text">Something went wrong! file an issue at https://dze.io/3nL29</text>
|
||||
<text data-testid="message" x="25" y="55" className="text small">
|
||||
<tspan x="25" dy="18">{encodeHTML(this.props.message)}</tspan>
|
||||
<tspan x="25" dy="18" className="gray">{this.props.secondaryMessage}</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
22
src/components/FlexLayout.tsx
Normal file
22
src/components/FlexLayout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
export default class FlexLayout extends React.Component<{
|
||||
items: Array<JSX.Element | string | undefined>
|
||||
gap: number
|
||||
direction?: 'column'
|
||||
}> {
|
||||
public render() {
|
||||
return this.props.items.filter(Boolean).map((item, index) => (
|
||||
<g key={index} transform={this.getGap(index)}>{item}</g>
|
||||
))
|
||||
}
|
||||
|
||||
private getGap(index: number) {
|
||||
const gap = this.props.gap * index
|
||||
let transform = `translate(${gap}, 0)`
|
||||
if (this.props.direction === 'column') {
|
||||
transform = `translate(0, ${gap})`
|
||||
}
|
||||
return transform
|
||||
}
|
||||
}
|
@@ -1,70 +1,115 @@
|
||||
import { request, logger, CONSTANTS, getLevel, CustomError } from '../common/utils'
|
||||
import { request, CONSTANTS, getLevel, profileGraphRequest, CustomError, formatDateNumber, formatDate, getColorOfLanguage } from '../common/utils'
|
||||
import retryer from '../common/retryer'
|
||||
import languageColor from '../../themes/language-bar.json'
|
||||
|
||||
export interface data {
|
||||
name: string
|
||||
size: number
|
||||
color: string
|
||||
recentSize: number
|
||||
}
|
||||
import { CodeStatsHistoryGraph, TopLanguages } from '../interfaces'
|
||||
|
||||
export async function fetchProfile(username: string) {
|
||||
if (!username) throw Error('Invalid Username')
|
||||
|
||||
const response = await retryer(request, {login: username})
|
||||
const response = await retryer(request, username)
|
||||
|
||||
return {
|
||||
username,
|
||||
xp: response.data.total_xp,
|
||||
recentXp: response.data.new_xp,
|
||||
level: getLevel(response.data.total_xp + response.data.new_xp)
|
||||
xp: response.total_xp,
|
||||
recentXp: response.new_xp,
|
||||
level: getLevel(response.total_xp)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTopLanguages(username: string) {
|
||||
if (!username) throw Error("Invalid username");
|
||||
export async function fetchHistory(username: string, days: number) {
|
||||
if (!username) throw Error('Invalid Username')
|
||||
|
||||
let res = await retryer(request, { login: username });
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - (days - 1))
|
||||
|
||||
let repoNodes = res.data.languages;
|
||||
const body = `{
|
||||
profile(username: "${username}") {
|
||||
day_language_xps: dayLanguageXps(since: "${formatDate(date)}") {date language xp}
|
||||
}
|
||||
}`
|
||||
|
||||
// Remap nodes
|
||||
const list = []
|
||||
for (const key in repoNodes) {
|
||||
const item = repoNodes[key]
|
||||
list.push({
|
||||
name: key,
|
||||
color: key in languageColor ? languageColor[key as keyof typeof languageColor].color || '#000000' : '#000000',
|
||||
xp: item.xps,
|
||||
recentXp: item.new_xps + item.xps,
|
||||
lvl: Math.trunc(Math.floor(CONSTANTS.LEVEL_FACTOR * Math.sqrt(item.xps)))
|
||||
})
|
||||
const response = await retryer<Promise<CodeStatsHistoryGraph>>(profileGraphRequest, body)
|
||||
if (response.errors) {
|
||||
throw new CustomError(response.errors[0].message, 'MAX_RETRY')
|
||||
}
|
||||
|
||||
repoNodes = list
|
||||
.filter((node) => {
|
||||
return node.xp > 0;
|
||||
const result: Record<string /* Date */, Array<{
|
||||
xp: number
|
||||
language: string
|
||||
}>> = {}
|
||||
|
||||
const languagesData: Record<string, number> = {}
|
||||
|
||||
for (const data of response.data.profile.day_language_xps) {
|
||||
let day = result[data.date]
|
||||
if (!day) {
|
||||
day = []
|
||||
}
|
||||
day.push({
|
||||
xp: data.xp,
|
||||
language: data.language
|
||||
})
|
||||
.sort((a, b) => b.xp - a.xp)
|
||||
.reduce((acc, prev) => {
|
||||
if (data.language in languagesData) {
|
||||
languagesData[data.language] += data.xp
|
||||
} else {
|
||||
languagesData[data.language] = data.xp
|
||||
}
|
||||
|
||||
result[data.date] = day
|
||||
}
|
||||
|
||||
for (const key of Object.keys(result)) {
|
||||
const item = result[key]
|
||||
result[key] = item.sort((a, b) => languagesData[b.language] - languagesData[a.language])
|
||||
}
|
||||
|
||||
const keys = Object.keys(result)
|
||||
|
||||
for (const day of keys) {
|
||||
if (keys.indexOf(day) === 0 && day === formatDate(date)) {
|
||||
continue
|
||||
}
|
||||
const date2 = new Date(day)
|
||||
date2.setDate(date2.getDate() - 1)
|
||||
const oldDate = formatDate(date2)
|
||||
if (!(oldDate in result)) {
|
||||
result[oldDate] = []
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return Object.keys(result).map((el) => {
|
||||
return {
|
||||
...acc,
|
||||
[prev.name]: {
|
||||
name: prev.name,
|
||||
color: prev.color,
|
||||
size: prev.xp,
|
||||
recentSize: prev.recentXp
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
data: result[el],
|
||||
day: el,
|
||||
total: result[el].reduce((prvs, crnt) => prvs + crnt.xp, 0)
|
||||
}
|
||||
}).sort((a, b) => a.day < b.day ? 1 : -1)
|
||||
|
||||
const topLangs = Object.keys(repoNodes)
|
||||
// .slice(0, 5)
|
||||
.reduce((result: Record<string, any>, key) => {
|
||||
result[key] = repoNodes[key];
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return topLangs as Record<string, data>
|
||||
}
|
||||
|
||||
export async function fetchTopLanguages(username: string): Promise<TopLanguages> {
|
||||
if (!username) throw Error("Invalid username")
|
||||
|
||||
let res = await retryer(request, username)
|
||||
|
||||
const langs = res.languages
|
||||
|
||||
const resp = Object.keys(langs)
|
||||
.map((key) => {
|
||||
const item = langs[key]
|
||||
return {
|
||||
xp: item.xps,
|
||||
recentXp: item.new_xps,
|
||||
color: getColorOfLanguage(key),
|
||||
name: key,
|
||||
level: getLevel(item.xps)
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (b.xp + b.recentXp) - (a.xp + a.recentXp))
|
||||
|
||||
return {
|
||||
username,
|
||||
langs: resp
|
||||
}
|
||||
}
|
||||
|
15
src/fetcher/interface.d.ts
vendored
15
src/fetcher/interface.d.ts
vendored
@@ -1,15 +0,0 @@
|
||||
export interface CodeStatsResponse {
|
||||
user: string
|
||||
error?: string
|
||||
total_xp: number
|
||||
new_xp: number
|
||||
machines: Record<string, {
|
||||
xps: number
|
||||
new_xps: number
|
||||
}>
|
||||
languages: Record<string, {
|
||||
xps: number
|
||||
new_xps: number
|
||||
}>
|
||||
dates: Record<string, number>
|
||||
}
|
@@ -1,92 +0,0 @@
|
||||
const calculateCircleProgress = (value: number) => {
|
||||
let radius = 40;
|
||||
let c = Math.PI * (radius * 2);
|
||||
|
||||
if (value < 0) value = 0;
|
||||
if (value > 100) value = 100;
|
||||
|
||||
let percentage = ((100 - value) / 100) * c;
|
||||
return percentage;
|
||||
};
|
||||
|
||||
const getProgressAnimation = ({ progress }: {progress: number}) => {
|
||||
return `
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: ${calculateCircleProgress(0)};
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: ${calculateCircleProgress(progress)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
export const 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
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"};
|
||||
}
|
||||
|
||||
.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 })}
|
||||
`;
|
||||
};
|
50
src/interfaces.d.ts
vendored
Normal file
50
src/interfaces.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface CodeStatsResponse {
|
||||
user: string
|
||||
error?: string
|
||||
total_xp: number
|
||||
new_xp: number
|
||||
machines: Record<string, {
|
||||
xps: number
|
||||
new_xps: number
|
||||
}>
|
||||
languages: Record<string, {
|
||||
xps: number
|
||||
new_xps: number
|
||||
}>
|
||||
dates: Record<string, number>
|
||||
}
|
||||
|
||||
export interface CodeStatsHistoryGraph {
|
||||
data: {
|
||||
profile: {
|
||||
day_language_xps: Array<{
|
||||
xp: number,
|
||||
language: string
|
||||
date: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
errors?: Array<{
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
username: string
|
||||
xp: number
|
||||
recentXp: number
|
||||
level: number
|
||||
}
|
||||
|
||||
export interface TopLanguages {
|
||||
username: string
|
||||
langs: Array<TopLanguage>
|
||||
}
|
||||
|
||||
export interface TopLanguage {
|
||||
xp: number
|
||||
recentXp: number
|
||||
color: string
|
||||
name: string
|
||||
level: number
|
||||
}
|
Reference in New Issue
Block a user