Merge remote-tracking branch 'origin/monorepo' into intermediate-merge

This commit is contained in:
martin-mfg
2026-02-03 12:26:07 +01:00
83 changed files with 2448 additions and 1926 deletions
+3
View File
@@ -72,3 +72,6 @@ jobs:
- name: Lint (knip)
run: pnpm run lint:knip
- name: Typecheck
run: pnpm run typecheck
+3
View File
@@ -29,6 +29,9 @@ apps/frontend/.env
apps/frontend/src/backend
apps/frontend/build
build-ts
tsconfig.tsbuildinfo
# IDE
.idea/
.vscode/*
+3 -2
View File
@@ -1,8 +1,9 @@
{
"markdown.extension.toc.levels": "1..3",
"editor.formatOnSave": true,
"editor.defaultFormatter": "prettier.prettier-vscode",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.tabSize": 2
}
},
"cSpell.words": ["Wakatime"]
}
+2
View File
@@ -161,11 +161,13 @@ const statsFetcher = async ({
const repoNodesWithStars = repoNodes.filter(
(node) => node.stargazers.totalCount !== 0,
);
hasNextPage =
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
repoNodes.length === repoNodesWithStars.length &&
res.data.data.user.repositories.pageInfo.hasNextPage;
endCursor = res.data.data.user.repositories.pageInfo.endCursor;
}
+12 -7
View File
@@ -2,29 +2,33 @@
"name": "frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@reduxjs/toolkit": "2.11.2",
"axios": "^1",
"axios-cache-interceptor": "^1",
"daisyui": "2.31.0",
"emoji-name-map": "^2.0.3",
"github-username-regex": "^1.0.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "^4.11.0",
"react-loading-skeleton": "^3.3.1",
"react-redux": "^8.1.3",
"react-router-dom": "^6.18.0",
"react-redux": "9.2.0",
"react-spinners": "^0.13.8",
"react-toastify": "^9.1.3",
"redux": "^4.2.1",
"redux": "5.0.1",
"save-svg-as-png": "^1.4.17",
"uuid": "^9.0.1",
"word-wrap": "^1.2.5"
},
"devDependencies": {
"@types/react": "18.3.27",
"@types/react-dom": "18.3.7",
"@types/uuid": "9.0.8",
"@vitejs/plugin-react-swc": "4.2.2",
"autoprefixer": "^10.4.16",
"clsx": "2.1.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "7.3.1",
@@ -34,7 +38,8 @@
"dev": "vite",
"build": "vite build",
"build-trends": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"homepage": "/frontend",
"browserslist": {
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-3
View File
@@ -1,3 +0,0 @@
import { authenticate, getUserMetadata, deleteAccount } from "./user";
export { authenticate, getUserMetadata, deleteAccount };
@@ -2,10 +2,16 @@ import axios from "axios";
import { HOST } from "../constants";
const authenticate = async (code, privateAccess, userKey) => {
const authenticate = async (
code: string,
privateAccess: boolean,
userKey: string,
): Promise<string> => {
try {
const fullUrl = `https://${HOST}/api/authenticate?code=${code}&private_access=${privateAccess}&user_key=${userKey}`;
const result = await axios.post(fullUrl);
const result = await axios.post<{ userId: string; needDowngrade: boolean }>(
fullUrl,
);
const { userId, needDowngrade } = result.data;
if (needDowngrade) {
console.info(
@@ -20,10 +26,17 @@ const authenticate = async (code, privateAccess, userKey) => {
}
};
const getUserMetadata = async (userKey) => {
interface UserMetaDataResponse {
token: string;
privateAccess: string;
}
const getUserMetadata = async (
userKey: string,
): Promise<null | UserMetaDataResponse> => {
try {
const fullUrl = `https://${HOST}/api/user-access?user_key=${userKey}`;
const result = await axios.get(fullUrl);
const result = await axios.get<UserMetaDataResponse>(fullUrl);
return result.data;
} catch (error) {
console.error(error);
@@ -31,7 +44,10 @@ const getUserMetadata = async (userKey) => {
}
};
const deleteAccount = async (userId, userKey) => {
const deleteAccount = async (
_userId: string,
userKey: string,
): Promise<unknown> => {
try {
const fullUrl = `https://${HOST}/api/delete-user?user_key=${userKey}`;
const result = await axios.get(fullUrl);
@@ -28,11 +28,21 @@ const cachedAxios = setupCache(axios, {
axios.get = cachedAxios.get.bind(cachedAxios);
axios.post = cachedAxios.post.bind(cachedAxios);
export function clearAxiosCache() {
cachedAxios.storage.clear();
export function clearAxiosCache(): void {
void cachedAxios.storage.clear?.();
}
function createMockResponse(data, config) {
function createMockResponse<TData, TConfig>(
data: TData,
config: TConfig,
): Promise<{
data: TData;
status: 200;
statusText: string;
headers: Record<string, never>;
request: Record<string, never>;
config: TConfig;
}> {
return Promise.resolve({
data,
status: 200,
@@ -44,9 +54,9 @@ function createMockResponse(data, config) {
}
// store shouldMock outside React context so the interceptor can access it
let shouldMock = null;
let shouldMock = false;
export function setShouldMock(newShouldMock) {
export function setShouldMock(newShouldMock: boolean): void {
shouldMock = newShouldMock;
}
@@ -58,7 +68,17 @@ axios.defaults.adapter = async (config) => {
return defaultAdapter(config);
}
const params = config.data ? JSON.parse(config.data) : {};
interface Params {
query?: string;
variables?: {
login?: string;
repo?: string;
gistName?: string;
};
}
const params = (
config.data ? JSON.parse(config.data as string) : {}
) as Params;
if (
config.url === "https://api.github.com/graphql" &&
@@ -93,8 +113,9 @@ axios.defaults.adapter = async (config) => {
if (
config.url === "https://api.github.com/graphql" &&
params.query?.includes("fragment RepoInfo on Repository {") &&
params.variables?.login === "anuraghazra" &&
params.variables?.repo === "github-readme-stats"
params.variables &&
params.variables.login === "anuraghazra" &&
params.variables.repo === "github-readme-stats"
) {
return createMockResponse(repository, config);
}
@@ -1,80 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import SVG from "./SVG";
import { classnames } from "../../utils";
import { HOST } from "../../constants";
export const Image = ({ imageSrc, stage, compact, extraClasses = "" }) => {
const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`;
return (
<div className={`${extraClasses} relative w-full relative`}>
<SVG
className="object-cover"
url={fullImageSrc}
compact={compact}
stage={stage}
/>
</div>
);
};
Image.propTypes = {
imageSrc: PropTypes.string.isRequired,
stage: PropTypes.number.isRequired,
compact: PropTypes.bool,
extraClasses: PropTypes.string,
};
Image.defaultProps = {
compact: false,
extraClasses: "",
};
export const Card = ({
title,
description,
imageSrc,
stage,
selected,
compact,
fixedSize,
}) => {
return (
<div
className={classnames(
"p-6 rounded border-2",
fixedSize ? "h-[370px] w-[510px]" : "",
selected
? "border-blue-500 bg-blue-50"
: "border-gray-200 bg-white hover:bg-gray-50",
)}
>
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
<Image
imageSrc={imageSrc}
compact={compact}
extraClasses={fixedSize ? "flex justify-center" : ""}
stage={stage}
/>
</div>
);
};
Card.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
imageSrc: PropTypes.string.isRequired,
stage: PropTypes.number.isRequired,
selected: PropTypes.bool,
compact: PropTypes.bool,
fixedSize: PropTypes.string,
};
Card.defaultProps = {
selected: false,
compact: false,
fixedSize: false,
};
@@ -0,0 +1,43 @@
import type { JSX } from "react";
import clsx from "clsx";
import { CardImage } from "./CardImage";
interface CardProps {
title: string;
description: string;
imageSrc: string;
stage: number;
selected?: boolean;
compact?: boolean;
fixedSize?: boolean;
}
export const Card = ({
title,
description,
imageSrc,
stage,
selected = false,
compact = false,
fixedSize = false,
}: CardProps): JSX.Element => {
return (
<div
className={clsx("p-6 rounded border-2", {
"h-[370px] w-[510px]": fixedSize,
"border-blue-500 bg-blue-50": selected,
"border-gray-200 bg-white hover:bg-gray-50": !selected,
})}
>
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
<CardImage
imageSrc={imageSrc}
compact={compact}
className={clsx({ "flex justify-center": fixedSize })}
stage={stage}
/>
</div>
);
};
@@ -0,0 +1,32 @@
import clsx from "clsx";
import { HOST } from "../../constants";
import { SvgInline } from "./SvgInline";
interface CardImageProps {
imageSrc: string;
stage: number;
compact?: boolean;
className?: string;
}
export const CardImage = ({
imageSrc,
stage,
compact = false,
className,
}: CardImageProps) => {
const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`;
return (
<div className={clsx("relative w-full relative", className)}>
<SvgInline
className="object-cover"
url={fullImageSrc}
compact={compact}
stage={stage}
/>
</div>
);
};
@@ -1,40 +1,55 @@
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import axios from "axios";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { createMockReq, createMockRes } from "../../mock-http";
import { createMockRequest, createMockResponse } from "../../mock-http.js";
// @ts-expect-error will be solved by npm package
import { default as router } from "../../backend/.vercel/output/functions/api.func/router.js";
import { setShouldMock } from "../../axios-override";
import { setShouldMock } from "../../axios-override.js";
import {
useIsAuthenticated,
useUserToken,
} from "../../redux/selectors/userSelectors";
import axios from "axios";
} from "../../redux/selectors/userSelectors.js";
const SvgInline = (props) => {
const [svg, setSvg] = useState(null);
interface SvgInlineProps {
url: string;
stage: number;
compact?: boolean;
className?: string;
forceLoading?: boolean;
}
export function SvgInline(props: SvgInlineProps): JSX.Element {
const {
url,
stage,
className,
compact = false,
forceLoading = false,
} = props;
const [svg, setSvg] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const containerRef = useRef(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const userToken = useUserToken();
const isAuthenticated = useIsAuthenticated();
const { url, stage } = props;
// provide shouldMock to non-react code in axios-override.js
useEffect(() => {
setShouldMock(stage === 0 || !isAuthenticated);
}, [isAuthenticated, props.stage]);
}, [isAuthenticated, stage]);
useEffect(() => {
let isCurrent = true;
const loadSvg = async () => {
process.env.PAT_1 = userToken;
window.process.env.PAT_1 = userToken as string;
setLoaded(false);
let body;
let body: string;
let status;
if (isAuthenticated && (!userToken || userToken === "placeholderPAT")) {
@@ -43,17 +58,19 @@ const SvgInline = (props) => {
}
if (stage === 4 && !isAuthenticated) {
let res = await axios.get(url);
const res = await axios.get<string>(url);
body = res.data;
status = res.status;
} else {
const req = createMockReq({
const req = createMockRequest({
method: "GET",
url: url,
url,
});
const res = createMockRes();
const res = createMockResponse();
// will be solved by npm package
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await router(req, res);
body = res._getBody();
body = res._getBody() as string;
status = res._getStatusCode();
}
@@ -68,12 +85,12 @@ const SvgInline = (props) => {
setSvg(body);
setLoaded(true);
};
loadSvg();
void loadSvg();
return () => {
isCurrent = false;
};
}, [userToken, isAuthenticated, props.url, props.stage]);
}, [userToken, isAuthenticated, url, stage]);
useEffect(() => {
if (loaded && svg && containerRef.current) {
@@ -91,8 +108,8 @@ const SvgInline = (props) => {
}
}, [loaded, svg]);
if (props.forceLoading || !loaded) {
if (props.compact) {
if (forceLoading || !loaded) {
if (compact) {
return <Skeleton style={{ paddingBottom: "58%" }} />;
}
// maximum dimensions of cards in SelectCard stage
@@ -100,21 +117,5 @@ const SvgInline = (props) => {
}
// Render a container div for the shadow DOM
return <div ref={containerRef} id="svgWrapper" className={props.className} />;
};
SvgInline.propTypes = {
className: PropTypes.any,
url: PropTypes.string.isRequired,
forceLoading: PropTypes.bool,
compact: PropTypes.bool,
stage: PropTypes.number.isRequired,
};
SvgInline.defaultProps = {
className: "",
forceLoading: false,
compact: false,
};
export default SvgInline;
return <div ref={containerRef} id="svgWrapper" className={className} />;
}
@@ -1,30 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { classnames } from "../../utils";
const Button = (props) => {
return (
<button
type="button"
{...props}
className={classnames(
props.className,
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
)}
>
{props.children}
</button>
);
};
Button.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
};
Button.defaultProps = {
className: "",
};
export { Button };
@@ -0,0 +1,22 @@
import clsx from "clsx";
import type { HTMLProps, JSX, ReactNode } from "react";
interface ButtonProps extends HTMLProps<HTMLButtonElement> {
children: ReactNode;
}
export function Button(props: ButtonProps): JSX.Element {
const { className, children, ...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",
className,
)}
>
{children}
</button>
);
}
@@ -1,34 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
const Checkbox = ({ question, variable, setVariable, disabled }) => {
return (
<div
className="flex inline-row mt-4"
onClick={() => setVariable(!variable)}
role="button"
>
<input
type="checkbox"
disabled={disabled}
checked={variable && !disabled ? "checked" : ""}
className="checkbox mr-2"
onChange={() => setVariable(!variable)}
/>
{question}
</div>
);
};
Checkbox.propTypes = {
question: PropTypes.string.isRequired,
variable: PropTypes.bool.isRequired,
setVariable: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
Checkbox.defaultProps = {
disabled: false,
};
export { Checkbox };
@@ -0,0 +1,30 @@
import type { JSX, ReactNode } from "react";
interface CheckboxProps {
question: ReactNode;
checked: boolean;
onCheckedChange: (value: boolean) => void;
disabled?: boolean;
}
export function Checkbox({
question,
checked,
onCheckedChange,
disabled = false,
}: CheckboxProps): JSX.Element {
return (
<label className="flex inline-row mt-4">
<input
type="checkbox"
disabled={disabled}
checked={checked}
className="checkbox mr-2"
onChange={() => {
onCheckedChange(!checked);
}}
/>
{question}
</label>
);
}
@@ -1,65 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { classnames } from "../../utils";
// options is of form [{value: '', label: '', disabled: true/false}]
const Input = ({
options,
selectedOption,
setSelectedOption,
disabled,
className,
}) => {
return (
<select
className={classnames(
"text-gray-700 bg-white select select-sm w-40 rounded-sm mt-4",
className,
)}
value={selectedOption.label}
onChange={(e) => {
setSelectedOption(
options.find((item) => item.label === e.target.value),
);
}}
disabled={disabled}
>
{options.map((option) => (
<option
key={option.value}
disabled={option.disabled}
className={classnames(
option.label === selectedOption.label && "bg-blue-200",
)}
>
{option.label}
</option>
))}
</select>
);
};
Input.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
disabled: PropTypes.bool,
}),
).isRequired,
selectedOption: PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}).isRequired,
setSelectedOption: PropTypes.func.isRequired,
disabled: PropTypes.bool,
className: PropTypes.string,
};
Input.defaultProps = {
disabled: false,
className: "",
};
export { Input };
@@ -0,0 +1,53 @@
import clsx from "clsx";
import type { JSX } from "react";
export interface SelectOption {
id: number;
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps {
options: Array<SelectOption>;
selectedOption: SelectOption;
className?: string;
disabled?: boolean;
onOptionChange: (option: SelectOption) => void;
}
export function Select({
options,
selectedOption,
onOptionChange,
disabled,
className,
}: SelectProps): JSX.Element {
return (
<select
className={clsx(
"text-gray-700 bg-white select select-sm w-40 rounded-sm mt-4",
className,
)}
value={selectedOption.value}
onChange={(e) => {
onOptionChange(options[e.target.selectedIndex] as SelectOption);
}}
disabled={disabled}
>
{options.map((option) => (
<option
key={option.value}
disabled={option.disabled}
className={clsx({
"bg-blue-200": option.value === selectedOption.value,
})}
>
{option.label}
</option>
))}
</select>
);
}
@@ -1,41 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { Section } from "./Section";
import { Checkbox } from "../Generic/Checkbox";
const CheckboxSection = ({
title,
text,
question,
variable,
setVariable,
disabled,
}) => {
return (
<Section title={title}>
{text && <p dangerouslySetInnerHTML={{ __html: text }} />}
<Checkbox
question={question}
variable={variable}
setVariable={setVariable}
disabled={disabled}
/>
</Section>
);
};
CheckboxSection.propTypes = {
title: PropTypes.string.isRequired,
text: PropTypes.string,
question: PropTypes.string.isRequired,
variable: PropTypes.bool.isRequired,
setVariable: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
CheckboxSection.defaultProps = {
disabled: false,
};
export { CheckboxSection };
@@ -0,0 +1,35 @@
import { Section } from "./Section";
import { Checkbox } from "../Generic/Checkbox";
import type { JSX } from "react";
interface CheckboxSectionProps {
title: string;
question: string;
checked: boolean;
text?: string;
disabled?: boolean;
onCheckedChange: (check: boolean) => void;
}
export function CheckboxSection({
title,
text,
question,
checked,
onCheckedChange,
disabled = false,
}: CheckboxSectionProps): JSX.Element {
return (
<Section title={title}>
{text && <p>{text}</p>}
<Checkbox
question={question}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
</Section>
);
}
@@ -1,51 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { Section } from "./Section";
import { Input } from "../Generic/Input";
export const DEFAULT_OPTION = {
id: 1,
label: "Normal",
disabled: false,
value: "normal",
};
const LanguagesLayoutSection = ({ selectedOption, setSelectedOption }) => {
const options = [
DEFAULT_OPTION,
{ id: 2, label: "Compact", disabled: false, value: "compact" },
{ id: 3, label: "Donut", disabled: false, value: "donut" },
{
id: 4,
label: "Vertical Donut",
disabled: false,
value: "donut-vertical",
},
{ id: 5, label: "Pie", disabled: false, value: "pie" },
{
id: 6,
label: "Only Languages",
disabled: false,
value: "compact&hide_progress=true",
},
];
return (
<Section title="Card Layout">
<p>Select a card layout.</p>
<Input
options={options}
selectedOption={selectedOption || DEFAULT_OPTION}
setSelectedOption={setSelectedOption}
/>
</Section>
);
};
LanguagesLayoutSection.propTypes = {
selectedOption: PropTypes.object.isRequired,
setSelectedOption: PropTypes.func.isRequired,
};
export { LanguagesLayoutSection };
@@ -0,0 +1,67 @@
import type { JSX } from "react";
import { Section } from "./Section";
import { Select } from "../Generic/Select";
import type { SelectOption } from "../Generic/Select";
export const DEFAULT_OPTION: SelectOption = {
id: 1,
label: "Normal",
disabled: false,
value: "normal",
};
const options: Array<SelectOption> = [
DEFAULT_OPTION,
{
id: 2,
label: "Compact",
disabled: false,
value: "compact",
},
{
id: 3,
label: "Donut",
disabled: false,
value: "donut",
},
{
id: 4,
label: "Vertical Donut",
disabled: false,
value: "donut-vertical",
},
{
id: 5,
label: "Pie",
disabled: false,
value: "pie",
},
{
id: 6,
label: "Only Languages",
disabled: false,
value: "compact&hide_progress=true",
},
];
interface LanguagesLayoutSectionProps {
selectedLanguageLayoutOption: SelectOption;
onLanguageLayoutOptionChange: (option: SelectOption) => void;
}
export function LanguagesLayoutSection({
selectedLanguageLayoutOption,
onLanguageLayoutOptionChange,
}: LanguagesLayoutSectionProps): JSX.Element {
return (
<Section title="Card Layout">
<p>Select a card layout.</p>
<Select
options={options}
selectedOption={selectedLanguageLayoutOption}
onOptionChange={onLanguageLayoutOptionChange}
/>
</Section>
);
}
@@ -1,77 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Section } from "./Section";
const NumericSection = ({
title,
text,
value,
setValue,
min,
max,
step,
disabled,
placeholder,
}) => {
const [internalValue, setInternalValue] = useState(value);
const debounceTimeout = useRef(null);
useEffect(() => {
// Debounce setValue
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
if (internalValue === value) {
return undefined;
}
debounceTimeout.current = setTimeout(() => {
setValue(internalValue);
}, 700);
return () => clearTimeout(debounceTimeout.current);
}, [internalValue]);
useEffect(() => {
setInternalValue(value);
}, [value]);
return (
<Section title={title}>
<p dangerouslySetInnerHTML={{ __html: text }} />
<input
type="number"
className="border border-gray-300 rounded px-2 py-1 mt-2 w-1/4"
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
min={min}
max={max}
step={step}
disabled={disabled}
placeholder={placeholder}
/>
</Section>
);
};
NumericSection.propTypes = {
title: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
value: PropTypes.number,
setValue: PropTypes.func.isRequired,
min: PropTypes.number,
max: PropTypes.number,
step: PropTypes.number,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
};
NumericSection.defaultProps = {
value: undefined,
min: undefined,
max: undefined,
step: 1,
disabled: false,
placeholder: "",
};
export { NumericSection };
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from "react";
import type { JSX, ReactNode } from "react";
import { Section } from "./Section";
interface NumericSectionProps {
title: string;
description: ReactNode;
value?: number | undefined;
onValueChange: (value: number | undefined) => void;
min: number;
max: number;
step?: number;
disabled?: boolean;
placeholder?: string;
}
export function NumericSection({
title,
description,
value,
onValueChange,
min,
max,
step = 1,
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]);
return (
<Section title={title}>
<p>{description}</p>
<input
type="number"
className="border border-gray-300 rounded px-2 py-1 mt-2 w-1/4"
value={internalValue ?? ""}
onChange={(e) => {
setInternalValue(e.target.value);
}}
min={min}
max={max}
step={step}
disabled={disabled}
placeholder={placeholder}
/>
</Section>
);
}
@@ -1,95 +0,0 @@
/* eslint-disable react/no-array-index-key */
import React from "react";
import PropTypes from "prop-types";
import {
FaArrowLeft as LeftArrowIcon,
FaArrowRight as RightArrowIcon,
} from "react-icons/fa";
import { classnames } from "../../utils";
const ProgressSection = ({ num, item, passed, isActive, onClick }) => {
return (
<button
className={classnames(
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
passed ? "border-blue-500" : "border-gray-500",
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
)}
type="button"
onClick={onClick}
>
<div
className={classnames(
"text-lg font-bold",
passed ? "text-blue-500" : "text-gray-500",
isActive ? "-mt-[4px]" : "",
)}
>
{`Step ${num + 1}`}
</div>
<div className={classnames(passed ? "text-gray-700" : "text-gray-500")}>
{item}
</div>
</button>
);
};
ProgressSection.propTypes = {
num: PropTypes.number.isRequired,
item: PropTypes.string.isRequired,
passed: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
};
const ProgressBar = ({ items, currItem, setCurrItem }) => {
const leftDisabled = currItem === 0;
const rightDisabled = currItem === 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={classnames(
"w-8 h-8",
leftDisabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 cursor-pointer",
)}
onClick={() => setCurrItem(Math.max(currItem - 1, 0))}
/>
<div className="px-2 flex-grow flex flex-row">
{items.map((item, index) => {
return (
<ProgressSection
num={index}
key={index}
item={item}
passed={currItem >= index}
isActive={currItem === index}
onClick={() => setCurrItem(index)}
/>
);
})}
</div>
<RightArrowIcon
className={classnames(
"w-8 h-8",
rightDisabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 cursor-pointer",
)}
onClick={() => setCurrItem(Math.min(currItem + 1, items.length - 1))}
/>
</div>
);
};
ProgressBar.propTypes = {
items: PropTypes.array.isRequired,
currItem: PropTypes.number.isRequired,
setCurrItem: PropTypes.func.isRequired,
};
export { ProgressBar };
@@ -0,0 +1,100 @@
import clsx from "clsx";
import type { JSX, MouseEventHandler } from "react";
import {
FaArrowLeft as LeftArrowIcon,
FaArrowRight as RightArrowIcon,
} from "react-icons/fa";
interface ProgressSectionProps {
num: number;
item: string;
passed: boolean;
isActive: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
}
function ProgressSection({
num,
item,
passed,
isActive,
onClick,
}: ProgressSectionProps): JSX.Element {
return (
<button
className={clsx(
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
passed ? "border-blue-500" : "border-gray-500",
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
)}
type="button"
onClick={onClick}
>
<div
className={clsx("text-lg font-bold", {
"text-blue-500": passed,
"text-gray-500": !passed,
"-mt-[4px]": isActive,
})}
>
{`Step ${num + 1}`}
</div>
<div className={passed ? "text-gray-700" : "text-gray-500"}>{item}</div>
</button>
);
}
interface ProgressBarProps {
items: Array<string>;
currItemIndex: number;
onItemClick: (itemIndex: number) => void;
}
export function ProgressBar({
items,
currItemIndex,
onItemClick,
}: ProgressBarProps): JSX.Element {
const leftDisabled = currItemIndex === 0;
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,
})}
onClick={() => {
onItemClick(Math.max(currItemIndex - 1, 0));
}}
/>
<div className="px-2 flex-grow flex flex-row">
{items.map((item, index) => {
return (
<ProgressSection
num={index}
key={item} // each step should have a unique name
item={item}
passed={currItemIndex >= index}
isActive={currItemIndex === index}
onClick={() => {
onItemClick(index);
}}
/>
);
})}
</div>
<RightArrowIcon
className={clsx("w-8 h-8", {
"text-gray-400 cursor-not-allowed": rightDisabled,
"text-gray-700 cursor-pointer": !rightDisabled,
})}
onClick={() => {
onItemClick(Math.min(currItemIndex + 1, items.length - 1));
}}
/>
</div>
);
}
@@ -1,9 +1,13 @@
import React from "react";
import PropTypes from "prop-types";
import type { JSX, ReactNode } from "react";
import { HiOutlineLightningBolt as LightningIcon } from "react-icons/hi";
const Section = (props) => {
interface SectionProps {
title: string;
children: ReactNode;
}
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">
@@ -15,22 +19,10 @@ const Section = (props) => {
<div className="flex-grow pl-4">
<h2 className="font-medium title-font text-sm text-gray-900 mb-1 tracking-wider">
{props.title}
{title}
</h2>
{props.children}
{children}
</div>
</div>
);
};
Section.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
};
Section.defaultProps = {
title: "Test",
children: <p className="leading-relaxed">This is a test!</p>,
};
export { Section };
}
@@ -1,39 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { Section } from "./Section";
import { Input } from "../Generic/Input";
export const DEFAULT_OPTION = {
id: 1,
label: "Rank",
disabled: false,
value: "default",
};
const StatsRankSection = ({ selectedOption, setSelectedOption }) => {
const options = [
DEFAULT_OPTION,
{ id: 2, label: "Percentile", disabled: false, value: "percentile" },
{ id: 3, label: "GitHub", disabled: false, value: "github" },
{ id: 4, label: "None", disabled: false, value: "default&hide_rank=true" },
];
return (
<Section title="Progress Style">
<p>Select a progress style.</p>
<Input
options={options}
selectedOption={selectedOption || DEFAULT_OPTION}
setSelectedOption={setSelectedOption}
/>
</Section>
);
};
StatsRankSection.propTypes = {
selectedOption: PropTypes.object.isRequired,
setSelectedOption: PropTypes.func.isRequired,
};
export { StatsRankSection };
@@ -0,0 +1,41 @@
import type { JSX } from "react";
import { Select } from "../Generic/Select";
import type { SelectOption } from "../Generic/Select";
import { Section } from "./Section";
export const DEFAULT_OPTION: SelectOption = {
id: 1,
label: "Rank",
value: "default",
disabled: false,
};
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 },
];
interface StatsRankSectionProps {
selectedOption: SelectOption;
onOptionChange: (option: SelectOption) => void;
}
export function StatsRankSection({
selectedOption,
onOptionChange,
}: StatsRankSectionProps): JSX.Element {
return (
<Section title="Progress Style">
<p>Select a progress style.</p>
<Select
options={options}
selectedOption={selectedOption}
onOptionChange={onOptionChange}
/>
</Section>
);
}
@@ -1,73 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Section } from "./Section";
import { classnames } from "../../utils";
const TextSection = ({
title,
description,
value,
setValue,
disabled,
placeholder,
onPaste,
}) => {
const [internalValue, setInternalValue] = useState(value);
const debounceTimeout = useRef(null);
useEffect(() => {
setInternalValue(value);
}, [value]);
useEffect(() => {
// Debounce setValue
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
if (internalValue === value) {
return undefined;
}
debounceTimeout.current = setTimeout(() => {
setValue(internalValue);
}, 700);
// return cleanup function:
return () => clearTimeout(debounceTimeout.current);
}, [internalValue]);
return (
<Section title={title}>
<p>{description}</p>
<input
type="text"
className={classnames(
"border border-gray-300 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
disabled ? "cursor-not-allowed" : "",
)}
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
disabled={disabled}
placeholder={placeholder}
onPaste={onPaste}
/>
</Section>
);
};
TextSection.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
setValue: PropTypes.func.isRequired,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onPaste: PropTypes.func,
};
TextSection.defaultProps = {
disabled: false,
placeholder: "",
onPaste: undefined,
};
export { TextSection };
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from "react";
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
import clsx from "clsx";
import { Section } from "./Section";
interface TextSectionProps {
title: string;
description: ReactNode;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
onPaste?: ClipboardEventHandler<HTMLInputElement>;
}
export function TextSection({
title,
description,
value,
onValueChange,
disabled = false,
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]);
return (
<Section title={title}>
<p>{description}</p>
<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": disabled },
)}
value={internalValue}
onChange={(e) => {
setInternalValue(e.target.value);
}}
disabled={disabled}
placeholder={placeholder}
onPaste={onPaste}
/>
</Section>
);
}
@@ -1,43 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { Section } from "./Section";
import { Input } from "../Generic/Input";
export const DEFAULT_OPTION = {
id: 1,
label: "Normal",
disabled: false,
value: "default",
};
const WakatimeLayoutSection = ({ selectedOption, setSelectedOption }) => {
const options = [
DEFAULT_OPTION,
{ id: 2, label: "Compact", disabled: false, value: "compact" },
{
id: 3,
label: "Text Only",
disabled: false,
value: "default&hide_progress=true&card_width=315",
},
];
return (
<Section title="Card Layout">
<p>Select a card layout.</p>
<Input
options={options}
selectedOption={selectedOption || DEFAULT_OPTION}
setSelectedOption={setSelectedOption}
/>
</Section>
);
};
WakatimeLayoutSection.propTypes = {
selectedOption: PropTypes.object.isRequired,
setSelectedOption: PropTypes.func.isRequired,
};
export default WakatimeLayoutSection;
@@ -0,0 +1,49 @@
import type { JSX } from "react";
import { Section } from "./Section";
import { Select } from "../Generic/Select";
import type { SelectOption } from "../Generic/Select";
export const DEFAULT_OPTION: SelectOption = {
id: 1,
label: "Normal",
disabled: false,
value: "default",
};
const options: Array<SelectOption> = [
DEFAULT_OPTION,
{
id: 2,
label: "Compact",
disabled: false,
value: "compact",
},
{
id: 3,
label: "Text Only",
disabled: false,
value: "default&hide_progress=true&card_width=315",
},
];
interface WakatimeLayoutSectionProps {
selectedOption: SelectOption;
onOptionChange: (option: SelectOption) => void;
}
export function WakatimeLayoutSection({
selectedOption,
onOptionChange,
}: WakatimeLayoutSectionProps): JSX.Element {
return (
<Section title="Card Layout">
<p>Select a card layout.</p>
<Select
options={options}
selectedOption={selectedOption}
onOptionChange={onOptionChange}
/>
</Section>
);
}
@@ -1,6 +1,6 @@
const PROD = false;
const PROD = false as boolean;
export const USE_LOGGER = true;
export const USE_LOGGER = true as boolean;
export const CLIENT_ID = "Ov23lilAc5biyyRY0K1u";
@@ -20,7 +20,7 @@ export const DEMO_WAKATIME_USER = "ffflabs";
window.process = {
env: {
FETCH_MULTI_PAGE_STARS: 10,
FETCH_MULTI_PAGE_STARS: "10",
PAT_1: "placeholderPAT", // so the backend's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
},
};
} as (typeof window)["process"];
-4
View File
@@ -1,4 +0,0 @@
// Safe browser stub for dotenv
export function config() {
return { parsed: {} };
}
+4
View File
@@ -0,0 +1,4 @@
// Safe browser stub for dotenv
export function config(): { parsed: Record<string, never> } {
return { parsed: {} };
}
+14
View File
@@ -0,0 +1,14 @@
declare global {
interface CustomProcess {
env: {
FETCH_MULTI_PAGE_STARS: string | undefined;
PAT_1: string | undefined;
};
}
interface Window {
process?: CustomProcess;
}
}
export {};
+3 -5
View File
@@ -1,13 +1,11 @@
import "./axios-override";
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import configureStore from "./redux/store";
import { AppTrends } from "./pages/App";
import "./index.css";
import { store } from "./redux/store";
import { AppTrends } from "./pages/App/AppTrends";
export const store = configureStore();
import "./index.css";
const root = ReactDOM.createRoot(document.getElementById("root"));
-58
View File
@@ -1,58 +0,0 @@
export function createMockReq({
method = "GET",
url,
headers = {},
body = null,
} = {}) {
return {
method,
url,
headers,
body,
};
}
export function createMockRes() {
let statusCode = 200;
const headers = {};
let chunks = [];
const res = {
statusCode,
chunks,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
getHeader(name) {
return headers[name.toLowerCase()];
},
getHeaders() {
return { ...headers };
},
write(chunk) {
if (typeof chunk !== "string") {
chunk = String(chunk);
}
chunks.push(chunk);
},
end(chunk) {
res.write(chunk);
},
// --- Helpers for inspection ---
_getStatusCode() {
return statusCode;
},
_getHeaders() {
return { ...headers };
},
_getBody() {
return chunks.join("");
},
};
return res;
}
+96
View File
@@ -0,0 +1,96 @@
type HeaderMap = Partial<Record<string, string>>;
type CreateMockRequestOptions = {
url: string;
headers?: HeaderMap;
} & (
| {
method: "GET";
}
| {
method: "POST";
body: unknown;
}
);
type CreateMockRequestResult = {
url: string;
headers: HeaderMap;
} & (
| {
method: "GET";
}
| {
method: "POST";
body: unknown;
}
);
export function createMockRequest(
options: CreateMockRequestOptions,
): CreateMockRequestResult {
const { headers = {}, ...rest } = options;
return { ...rest, headers };
}
interface CreateMockResponseResult {
statusCode: number;
chunks: Array<unknown>;
setHeader(name: string, value: string): void;
getHeader(name: string): string | undefined;
getHeaders(): HeaderMap;
write(chunk: unknown): void;
end(chunk: unknown): void;
// --- Helpers for inspection ---
_getStatusCode(): number;
_getHeaders(): HeaderMap;
_getBody(): unknown;
}
export function createMockResponse(): CreateMockResponseResult {
const statusCode = 200;
const headers: HeaderMap = {};
const chunks: Array<unknown> = [];
const res: CreateMockResponseResult = {
statusCode,
chunks,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
getHeader(name) {
return headers[name.toLowerCase()];
},
getHeaders() {
return { ...headers };
},
write(chunk) {
if (typeof chunk !== "string") {
chunk = String(chunk);
}
chunks.push(chunk);
},
end(chunk) {
res.write(chunk);
},
_getStatusCode() {
return statusCode;
},
_getHeaders() {
return { ...headers };
},
_getBody() {
return chunks.join("");
},
};
return res;
}
+8
View File
@@ -0,0 +1,8 @@
export const CardType = {
STATS: "stats",
TOP_LANGS: "top-langs",
PIN: "pin",
GIST: "gist",
WAKATIME: "wakatime",
} as const;
export type CardType = (typeof CardType)[keyof typeof CardType];
+28
View File
@@ -0,0 +1,28 @@
export const STAGE_LABELS = [
{
title: "Login",
shortTitle: "Login",
},
{
title: "Select a Card",
shortTitle: "Select Card",
},
{
title: "Modify Card Parameters",
shortTitle: "Modify Parameters",
},
{
title: "Choose a Theme",
shortTitle: "Select Theme",
},
{
title: "Display your Card",
shortTitle: "Display Card",
},
] as const satisfies Array<{ title: string; shortTitle: string }>;
export type StageIndex = {
[K in keyof typeof STAGE_LABELS]: K extends `${infer N extends number}`
? N
: never;
}[keyof typeof STAGE_LABELS];
+19
View File
@@ -0,0 +1,19 @@
declare module "*.css";
declare module "*.png" {
const img: string;
export default img;
}
// This package doesn't have a @types counter part
declare module "save-svg-as-png" {
const saveSvgAsPng: (
element: Element,
filename: string,
options: {
scale: number;
encoderOptions: number;
},
) => void;
export { saveSvgAsPng };
}
-105
View File
@@ -1,105 +0,0 @@
import React, { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { BrowserRouter as Router } from "react-router-dom";
import {
logout as _logout,
setUserAccess as _setUserAccess,
} from "../../redux/actions/userActions";
import Header from "./Header";
import HomeScreen from "../Home";
import { getUserMetadata } from "../../api";
import {
useIsAuthenticated,
useUserKey,
useUserToken,
} from "../../redux/selectors/userSelectors";
import { toast, ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { clearAxiosCache } from "../../axios-override";
function App() {
const toMessage = (input) => {
if (typeof input === "string") {
return input;
}
if (input.reason?.message) {
return input.reason.message;
}
if (input.message) {
return input.message;
}
try {
return JSON.stringify(input);
} catch {
return "Unknown error";
}
};
const showError = (event) => {
toast.error(toMessage(event), {
position: "bottom-right",
autoClose: 1500,
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: true,
draggable: false,
progress: undefined,
});
};
window.addEventListener("error", (event) => {
showError(event);
});
window.addEventListener("unhandledrejection", (event) => {
showError(event);
});
const userToken = useUserToken();
useEffect(() => {
clearAxiosCache();
}, [userToken]);
const userKey = useUserKey();
const isAuthenticated = useIsAuthenticated();
const [stage, setStage] = useState(isAuthenticated ? 1 : 0);
const dispatch = useDispatch();
const setUserAccess = (access) =>
dispatch(_setUserAccess(access.token, access.privateAccess));
useEffect(() => {
if (isAuthenticated && stage === 0) {
setStage(1);
}
}, [isAuthenticated]);
useEffect(() => {
async function getPrivateAccess() {
if (userKey && userKey.length > 0) {
const userAccess = await getUserMetadata(userKey);
if (userAccess === null) {
dispatch(_logout(userKey));
} else {
setUserAccess(userAccess);
}
}
}
getPrivateAccess();
}, [userKey]);
return (
<div className="min-h-screen flex flex-col">
<Router basename="/frontend">
<Header stage={stage} setStage={setStage} />
<section className="bg-white text-gray-700 flex-grow">
<HomeScreen stage={stage} setStage={setStage} />
<ToastContainer />
</section>
</Router>
</div>
);
}
export default App;
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import { useDispatch } from "react-redux";
import { toast, ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import {
logout as _logout,
setUserAccess as _setUserAccess,
} from "../../redux/slices/user";
import { clearAxiosCache } from "../../axios-override";
import { HomeScreen } from "../Home/Home";
import { getUserMetadata } from "../../api/user";
import {
useIsAuthenticated,
useUserKey,
useUserToken,
} from "../../redux/selectors/userSelectors";
import type { StageIndex } from "../../models/Stage";
import { Header } from "./Header";
const toMessage = (
input: string | ErrorEvent | PromiseRejectionEvent,
): string => {
if (typeof input === "string") {
return input;
}
type MaybeErrorReason = { message: string } | null;
const reason = ("reason" in input ? input.reason : null) as MaybeErrorReason;
if (typeof reason?.message === "string" && !!reason.message.trim()) {
return reason.message;
}
if ("message" in input && input.message) {
return input.message;
}
try {
return JSON.stringify(input);
} catch {
return "Unknown error";
}
};
const showError = (event: ErrorEvent | PromiseRejectionEvent): void => {
toast.error(toMessage(event), {
position: "bottom-right",
autoClose: 1500,
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: true,
draggable: false,
});
};
export function AppTrends() {
const userKey = useUserKey();
const userToken = useUserToken();
const isAuthenticated = useIsAuthenticated();
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
const dispatch = useDispatch();
useEffect(() => {
const handler = (event: ErrorEvent | PromiseRejectionEvent) => {
showError(event);
};
window.addEventListener("error", handler);
window.addEventListener("unhandledrejection", handler);
return () => {
window.removeEventListener("error", handler);
window.removeEventListener("unhandledrejection", handler);
};
}, []);
useEffect(() => {
clearAxiosCache();
}, [userToken]);
{
/**
* This effect mus be executed only on page load,
* otherwise logged in user are unable to go back on first step
*/
const hasCheckedUserAuthStatusOnLoad = useRef(false);
useEffect(() => {
if (hasCheckedUserAuthStatusOnLoad.current) {
return;
}
hasCheckedUserAuthStatusOnLoad.current = true;
if (isAuthenticated && stage === 0) {
setStage(1);
}
}, [isAuthenticated, stage]);
}
useEffect(() => {
async function getPrivateAccess() {
if (userKey && userKey.length > 0) {
const userAccess = await getUserMetadata(userKey);
if (userAccess === null) {
dispatch(_logout({ userKey }));
} else {
dispatch(_setUserAccess(userAccess));
}
}
}
void getPrivateAccess();
}, [dispatch, userKey]);
return (
<div className="min-h-screen flex flex-col">
<Header currStageIndex={stage} onStageIndexChange={setStage} />
<section className="bg-white text-gray-700 flex-grow">
<HomeScreen stage={stage} setStage={setStage} />
<ToastContainer />
</section>
</div>
);
}
-108
View File
@@ -1,108 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
import appIcon from "../../assets/appLogo64.png";
import { classnames } from "../../utils";
import { FaGithub as GithubIcon } from "react-icons/fa";
import { ProgressBar } from "../../components/Home/Progress";
const propTypes = {
to: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
onClick: PropTypes.func,
className: PropTypes.string,
};
const defaultProps = {
onClick: null,
className: null,
};
const StandardLink = ({ to, children, onClick, className }) => (
<Link
to={to}
className={classnames(
"px-4 py-1 mr-3 rounded-sm bg-gray-200 hover:bg-gray-300 text-gray-700",
className,
)}
onClick={onClick}
>
{children}
</Link>
);
StandardLink.propTypes = propTypes;
StandardLink.defaultProps = defaultProps;
const MobileLink = ({ to, children, onClick, className }) => (
<Link
to={to}
className={classnames(
"block text-sm px-2 my-2 py-2 rounded-sm bg-gray-200 text-gray-700",
className,
)}
onClick={onClick}
>
{children}
</Link>
);
MobileLink.propTypes = propTypes;
MobileLink.defaultProps = defaultProps;
const Header = ({ stage, setStage }) => {
return (
<>
<div className="text-gray-100 bg-gray-800 shadow-md body-font z-50">
<div className="px-5 py-2 flex flex-wrap">
{/* Logo */}
<Link
to="/"
className="flex items-center title-font font-medium text-gray-50 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>
</Link>
{/* Star on GitHub */}
<div className="flex ml-auto items-center text-base justify-center">
<a
href="https://github.com/stats-organization/github-stats-extended"
target="_blank"
rel="noopener noreferrer"
>
<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>
</a>
</div>
</div>
</div>
<ProgressBar
items={[
"Login",
"Select Card",
"Modify Parameters",
"Select Theme",
"Display Card",
]}
currItem={stage}
setCurrItem={setStage}
/>
</>
);
};
Header.propTypes = {
stage: PropTypes.number.isRequired,
setStage: PropTypes.func.isRequired,
};
export default Header;
+59
View File
@@ -0,0 +1,59 @@
import type { JSX } from "react";
import appIcon from "../../assets/appLogo64.png";
import { FaGithub as GithubIcon } from "react-icons/fa";
import { ProgressBar } from "../../components/Home/Progress";
import { STAGE_LABELS } from "../../models/Stage";
import type { StageIndex } from "../../models/Stage";
interface HeaderProps {
currStageIndex: StageIndex;
onStageIndexChange: (stageIndex: StageIndex) => void;
}
const items = STAGE_LABELS.map((it) => it.shortTitle);
export function Header({
currStageIndex,
onStageIndexChange,
}: HeaderProps): JSX.Element {
return (
<>
<div className="text-gray-100 bg-gray-800 shadow-md body-font z-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"
>
<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">
<a
href="https://github.com/stats-organization/github-stats-extended"
target="_blank"
rel="noopener noreferrer"
>
<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>
</a>
</div>
</div>
</div>
<ProgressBar
items={items}
currItemIndex={currStageIndex}
onItemClick={(itemIndex) => {
onStageIndexChange(itemIndex as StageIndex);
}}
/>
</>
);
}
-3
View File
@@ -1,3 +0,0 @@
import AppTrends from "./AppTrends";
export { AppTrends };
@@ -1,18 +1,12 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { JSX } from "react";
import { useDispatch } from "react-redux";
import PropTypes from "prop-types";
import BounceLoader from "react-spinners/BounceLoader";
import axios from "axios";
import { v4 as uuidv4 } from "uuid";
import { CustomizeStage } from "./stages/Customize";
import { DisplayStage } from "./stages/Display";
import { LoginStage } from "./stages/Login";
import { SelectCardStage } from "./stages/SelectCard";
import { ThemeStage } from "./stages/Theme";
import { authenticate } from "../../api";
import { login as _login } from "../../redux/actions/userActions";
import { authenticate } from "../../api/user";
import { login } from "../../redux/slices/user";
import {
HOST,
DEMO_USER,
@@ -20,7 +14,7 @@ import {
DEMO_REPO,
DEMO_GIST,
} from "../../constants";
import { CardTypes } from "../../utils";
import { CardType } from "../../models/CardType";
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
@@ -29,9 +23,21 @@ import {
useIsAuthenticated,
usePrivateAccess,
} from "../../redux/selectors/userSelectors";
import axios from "axios";
import { STAGE_LABELS } from "../../models/Stage";
import type { StageIndex } from "../../models/Stage";
const HomeScreen = ({ stage, setStage }) => {
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";
interface HomeScreenProps {
stage: StageIndex;
setStage: (stageIndex: StageIndex) => void;
}
export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
const [isLoading, setIsLoading] = useState(false);
const userId = useUserId(DEMO_USER);
@@ -40,15 +46,13 @@ const HomeScreen = ({ stage, setStage }) => {
const dispatch = useDispatch();
const login = (newUserId, userKey) => dispatch(_login(newUserId, userKey));
// for stage two
const [selectedUserId, setSelectedUserId] = useState(userId);
const [selectedUserId, setSelectedUserId] = useState<string>(userId);
const [repo, setRepo] = useState(DEMO_REPO);
const [gist, setGist] = useState(DEMO_GIST);
const [wakatimeUser, setWakatimeUser] = useState(DEMO_WAKATIME_USER);
const [selectedCard, setSelectedCard] = useState("stats");
const [selectedCard, setSelectedCard] = useState<CardType>(CardType.STATS);
useEffect(() => {
setSelectedUserId(userId);
@@ -66,9 +70,11 @@ const HomeScreen = ({ stage, setStage }) => {
const [showTitle, setShowTitle] = useState(true);
const [showOwner, setShowOwner] = useState(false);
const [descriptionLines, setDescriptionLines] = useState();
const [descriptionLines, setDescriptionLines] = useState<
number | undefined
>();
const [customTitle, setCustomTitle] = useState("");
const [langsCount, setLangsCount] = useState();
const [langsCount, setLangsCount] = useState<number | undefined>();
const [showAllStats, setShowAllStats] = useState(false);
const [showIcons, setShowIcons] = useState(false);
const [includeAllCommits, setIncludeAllCommits] = useState(true);
@@ -77,95 +83,93 @@ const HomeScreen = ({ stage, setStage }) => {
const [theme, setTheme] = useState("default");
const resetCustomization = () => {
if (selectedCard === CardTypes.TOP_LANGS) {
const handleCardTypeChange = (cardType: CardType) => {
if (cardType === CardType.TOP_LANGS) {
setLangsCount(4);
}
if (selectedCard === CardTypes.WAKATIME) {
setLangsCount(6);
}
if (selectedCard === CardTypes.TOP_LANGS) {
setSelectedWakatimeLayout(WAKATIME_DEFAULT_LAYOUT);
}
if (selectedCard === CardTypes.WAKATIME) {
} else if (cardType === CardType.WAKATIME) {
setLangsCount(6);
setSelectedLanguagesLayout(LANGUAGES_DEFAULT_LAYOUT);
}
if (theme === "default" || theme === "default_repocard") {
if (selectedCard === CardTypes.PIN || selectedCard === CardTypes.GIST) {
if (cardType === CardType.PIN || cardType === CardType.GIST) {
setTheme("default_repocard");
} else {
setTheme("default");
}
}
setSelectedCard(cardType);
// Go to the next stage
setStage(2);
};
useEffect(() => {
resetCustomization();
}, [selectedCard]);
let fullSuffix = `${selectedCard === CardTypes.STATS ? "" : "/" + selectedCard}?`;
let fullSuffix = `${selectedCard === CardType.STATS ? "" : "/" + selectedCard}?`;
switch (selectedCard) {
case CardTypes.STATS:
case CardTypes.TOP_LANGS:
case CardType.STATS:
case CardType.TOP_LANGS:
fullSuffix += `username=${selectedUserId}`;
break;
case CardTypes.PIN:
case CardType.PIN:
fullSuffix += `username=${userId}&repo=${repo}`;
break;
case CardTypes.GIST:
case CardType.GIST:
fullSuffix += `id=${gist}`;
break;
case CardTypes.WAKATIME:
case CardType.WAKATIME:
fullSuffix += `username=${wakatimeUser}`;
break;
default:
selectedCard satisfies never;
}
if (
selectedStatsRank !== STATS_DEFAULT_RANK &&
selectedCard === CardTypes.STATS
selectedCard === CardType.STATS
) {
fullSuffix += `&rank_icon=${selectedStatsRank.value}`;
}
if (
selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT &&
selectedCard === CardTypes.TOP_LANGS
selectedCard === CardType.TOP_LANGS
) {
fullSuffix += `&layout=${selectedLanguagesLayout.value}`;
}
if (
selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT &&
selectedCard === CardTypes.WAKATIME
selectedCard === CardType.WAKATIME
) {
fullSuffix += `&layout=${selectedWakatimeLayout.value}`;
}
if (
!showTitle &&
(selectedCard === CardTypes.STATS ||
selectedCard === CardTypes.TOP_LANGS ||
selectedCard === CardTypes.WAKATIME)
(selectedCard === CardType.STATS ||
selectedCard === CardType.TOP_LANGS ||
selectedCard === CardType.WAKATIME)
) {
fullSuffix += "&hide_title=true";
}
if (
showOwner &&
(selectedCard === CardTypes.PIN || selectedCard === CardTypes.GIST)
(selectedCard === CardType.PIN || selectedCard === CardType.GIST)
) {
fullSuffix += "&show_owner=true";
}
if (descriptionLines && selectedCard === CardTypes.PIN) {
if (descriptionLines && selectedCard === CardType.PIN) {
fullSuffix += `&description_lines_count=${descriptionLines}`;
}
if (
customTitle &&
(selectedCard === CardTypes.STATS || selectedCard === CardTypes.WAKATIME)
(selectedCard === CardType.STATS || selectedCard === CardType.WAKATIME)
) {
const encodedTitle = encodeURIComponent(customTitle);
fullSuffix += `&custom_title=${encodedTitle}`;
@@ -173,34 +177,33 @@ const HomeScreen = ({ stage, setStage }) => {
if (
langsCount &&
(selectedCard === CardTypes.TOP_LANGS ||
selectedCard === CardTypes.WAKATIME)
(selectedCard === CardType.TOP_LANGS || selectedCard === CardType.WAKATIME)
) {
fullSuffix += `&langs_count=${langsCount}`;
}
if (showAllStats && selectedCard === CardTypes.STATS) {
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 === CardTypes.STATS) {
if (showIcons && selectedCard === CardType.STATS) {
fullSuffix += `&show_icons=true`;
}
if (includeAllCommits && selectedCard === CardTypes.STATS) {
if (includeAllCommits && selectedCard === CardType.STATS) {
fullSuffix += `&include_all_commits=true`;
}
if (
!enableAnimations &&
(selectedCard === CardTypes.STATS ||
selectedCard === CardTypes.TOP_LANGS ||
selectedCard === CardTypes.WAKATIME)
(selectedCard === CardType.STATS ||
selectedCard === CardType.TOP_LANGS ||
selectedCard === CardType.WAKATIME)
) {
fullSuffix += `&disable_animations=${!enableAnimations}`;
}
if (usePercent && selectedCard === CardTypes.WAKATIME) {
if (usePercent && selectedCard === CardType.WAKATIME) {
fullSuffix += `&display_format=percent`;
}
@@ -210,11 +213,11 @@ const HomeScreen = ({ stage, setStage }) => {
if (
!(
(theme === "default" &&
[CardTypes.STATS, CardTypes.TOP_LANGS, CardTypes.WAKATIME].includes(
selectedCard,
[CardType.STATS, CardType.TOP_LANGS, CardType.WAKATIME].includes(
selectedCard as never,
)) ||
(theme === "default_repocard" &&
[CardTypes.PIN, CardTypes.GIST].includes(selectedCard))
[CardType.PIN, CardType.GIST].includes(selectedCard as never))
)
) {
themeSuffix += `&theme=${theme}`;
@@ -223,38 +226,37 @@ const HomeScreen = ({ stage, setStage }) => {
// for stage five
const [gistUrl, setGistUrl] = useState("");
let guestHint;
switch (selectedCard) {
case CardTypes.STATS:
case CardTypes.TOP_LANGS:
guestHint = `username "${DEMO_USER}"`;
break;
case CardTypes.PIN:
guestHint = `repo "${DEMO_REPO}"`;
break;
case CardTypes.GIST:
guestHint = `Gist ID "${DEMO_GIST}"`;
break;
case CardTypes.WAKATIME:
guestHint = `WakaTime username "${DEMO_WAKATIME_USER}"`;
}
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(() => {
const fetchGistUrl = async (gistId) => {
async function fetchGistURL() {
try {
const fullUrl = `https://api.github.com/gists/${gistId}`;
const result = await axios.get(fullUrl);
return result.data.html_url;
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);
return "";
}
};
fetchGistUrl(gist).then(setGistUrl);
}
void fetchGistURL();
}, [gist]);
const contentSectionRef = useRef(null);
const contentSectionRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// scroll to top of content section if we're scrolled down
@@ -276,9 +278,9 @@ const HomeScreen = ({ stage, setStage }) => {
// If Github API returns the code parameter
if (url.includes("code=")) {
const tempPrivateAccess = url.includes("private");
const newUrl = url.split("?code=");
const redirect = `${url.split(HOST)[0]}${HOST}/frontend`;
window.history.pushState({}, null, redirect);
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 userKey = uuidv4();
const newUserId = await authenticate(
@@ -286,13 +288,15 @@ const HomeScreen = ({ stage, setStage }) => {
tempPrivateAccess,
userKey,
);
login(newUserId, userKey);
dispatch(login({ userId: newUserId, userKey }));
setIsLoading(false);
}
}
redirectCode();
}, []);
void redirectCode();
}, [dispatch]);
if (isLoading) {
return (
@@ -311,15 +315,7 @@ const HomeScreen = ({ stage, setStage }) => {
<div className="m-4 rounded-sm">
<div className="lg:p-4">
<div className="text-2xl text-gray-600 font-semibold">
{
[
"Login",
"Select a Card",
"Modify Card Parameters",
"Choose a Theme",
"Display your Card",
][stage]
}
{STAGE_LABELS[stage].title}
</div>
<div>
{stage === 0 && isAuthenticated ? (
@@ -360,17 +356,22 @@ const HomeScreen = ({ stage, setStage }) => {
)}
</div>
</div>
{stage === 0 && <LoginStage setCurrItem={setStage} />}
{stage === 0 && (
<LoginStage
onContinueAsGuest={() => {
setStage(1);
}}
/>
)}
{stage === 1 && (
<SelectCardStage
selectedCard={selectedCard}
setSelectedCard={setSelectedCard}
setStage={setStage}
selectedCardType={selectedCard}
onCardTypeChange={handleCardTypeChange}
/>
)}
{stage === 2 && (
<CustomizeStage
selectedCard={selectedCard || CardTypes.STATS}
selectedCard={selectedCard}
selectedStatsRank={selectedStatsRank}
setSelectedStatsRank={setSelectedStatsRank}
selectedLanguagesLayout={selectedLanguagesLayout}
@@ -411,46 +412,52 @@ const HomeScreen = ({ stage, setStage }) => {
)}
{stage === 3 && (
<ThemeStage
theme={theme}
setTheme={setTheme}
setStage={setStage}
fullSuffix={fullSuffix}
theme={theme}
onThemeChange={(theme) => {
setTheme(theme);
setStage(4);
}}
/>
)}
{stage === 4 && (
<DisplayStage
// eslint-disable-next-line consistent-return
filename={(() => {
switch (selectedCard) {
case CardTypes.STATS:
case CardTypes.TOP_LANGS:
case CardType.STATS:
case CardType.TOP_LANGS:
return `${selectedUserId}_card`;
case CardTypes.PIN:
case CardType.PIN:
return `${repo}_card`;
case CardTypes.GIST:
case CardType.GIST:
return `gist_card`;
case CardTypes.WAKATIME:
case CardType.WAKATIME:
return `${wakatimeUser}_card`;
default:
selectedCard satisfies never;
return "";
}
})()}
// eslint-disable-next-line consistent-return
link={(() => {
switch (selectedCard) {
case CardTypes.STATS:
case CardTypes.TOP_LANGS:
case CardType.STATS:
case CardType.TOP_LANGS:
return `https://${HOST}/api${themeSuffix}`;
case CardTypes.PIN: {
case CardType.PIN: {
let myRepo = repo;
if (!myRepo.includes("/")) {
myRepo = `${userId}/${myRepo}`;
}
return `https://github.com/${myRepo}`;
}
case CardTypes.GIST:
case CardType.GIST:
return gistUrl;
case CardTypes.WAKATIME:
case CardType.WAKATIME:
return `https://wakatime.com/@${wakatimeUser}`;
default:
selectedCard satisfies never;
return "";
}
})()}
themeSuffix={themeSuffix}
@@ -465,11 +472,4 @@ const HomeScreen = ({ stage, setStage }) => {
</div>
</div>
);
};
HomeScreen.propTypes = {
stage: PropTypes.number.isRequired,
setStage: PropTypes.func.isRequired,
};
export default HomeScreen;
}
-3
View File
@@ -1,3 +0,0 @@
import HomeScreen from "./Home";
export default HomeScreen;
@@ -1,14 +1,14 @@
import React from "react";
import PropTypes from "prop-types";
import type React from "react";
import type { JSX } from "react";
import { CardTypes } from "../../../utils";
import { Image } from "../../../components/Card/Card";
import { CardType } from "../../../models/CardType";
import { CardImage } from "../../../components/Card/CardImage";
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
import { TextSection } from "../../../components/Home/TextSection";
import { NumericSection } from "../../../components/Home/NumericSection";
import { StatsRankSection } from "../../../components/Home/StatsRankSection";
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
import WakatimeLayoutSection from "../../../components/Home/WakatimeLayoutSection";
import { WakatimeLayoutSection } from "../../../components/Home/WakatimeLayoutSection";
import {
DEMO_GIST,
DEMO_REPO,
@@ -16,8 +16,53 @@ import {
DEMO_WAKATIME_USER,
} from "../../../constants";
import { useIsAuthenticated } from "../../../redux/selectors/userSelectors";
import type { SelectOption } from "../../../components/Generic/Select";
import type { StageIndex } from "../../../models/Stage";
const CustomizeStage = ({
type Updater<T> = React.Dispatch<React.SetStateAction<T>>;
/** @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>;
showIcons: boolean;
setShowIcons: Updater<boolean>;
showAllStats: boolean;
setShowAllStats: Updater<boolean>;
includeAllCommits: boolean;
setIncludeAllCommits: Updater<boolean>;
enableAnimations: boolean;
setEnableAnimations: Updater<boolean>;
usePercent: boolean;
setUsePercent: Updater<boolean>;
fullSuffix: string;
setStage: (stageIndex: StageIndex) => void;
}
export function CustomizeStage({
selectedCard,
selectedStatsRank,
setSelectedStatsRank,
@@ -55,14 +100,14 @@ const CustomizeStage = ({
setUsePercent,
fullSuffix,
setStage,
}) => {
const cardType = selectedCard || CardTypes.STATS;
}: CustomizeStageProps): JSX.Element {
const cardType = selectedCard;
const isAuthenticated = useIsAuthenticated();
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">
{(cardType === CardTypes.STATS || cardType === CardTypes.TOP_LANGS) && (
{(cardType === CardType.STATS || cardType === CardType.TOP_LANGS) && (
<TextSection
title="Username"
description={
@@ -89,7 +134,7 @@ const CustomizeStage = ({
}
placeholder={`e.g. "${DEMO_USER}"`}
value={selectedUserId}
setValue={setSelectedUserId}
onValueChange={setSelectedUserId}
onPaste={(e) => {
e.preventDefault();
let newValue = e.clipboardData.getData("text");
@@ -97,7 +142,7 @@ const CustomizeStage = ({
if (newValue.endsWith("/")) {
newValue = newValue.slice(0, -1);
}
let parts = newValue.split("/");
const parts = newValue.split("/");
if (parts.length > 1) {
newValue = parts.slice(-1).join("/");
}
@@ -106,7 +151,7 @@ const CustomizeStage = ({
disabled={!isAuthenticated}
/>
)}
{cardType === CardTypes.PIN && (
{cardType === CardType.PIN && (
<TextSection
title="Repository"
description={
@@ -133,7 +178,7 @@ const CustomizeStage = ({
}
placeholder={`e.g. "${DEMO_REPO}"`}
value={repo}
setValue={setRepo}
onValueChange={setRepo}
onPaste={(e) => {
e.preventDefault();
let newValue = e.clipboardData.getData("text");
@@ -141,7 +186,7 @@ const CustomizeStage = ({
if (newValue.endsWith("/")) {
newValue = newValue.slice(0, -1);
}
let parts = newValue.split("/");
const parts = newValue.split("/");
if (parts.length > 2) {
newValue = parts.slice(-2).join("/");
}
@@ -150,7 +195,7 @@ const CustomizeStage = ({
disabled={!isAuthenticated}
/>
)}
{cardType === CardTypes.GIST && (
{cardType === CardType.GIST && (
<TextSection
title="Repository"
description={
@@ -177,7 +222,7 @@ const CustomizeStage = ({
}
placeholder={`e.g. "${DEMO_GIST}"`}
value={gist}
setValue={setGist}
onValueChange={setGist}
onPaste={(e) => {
e.preventDefault();
let newValue = e.clipboardData.getData("text");
@@ -185,7 +230,7 @@ const CustomizeStage = ({
if (newValue.endsWith("/")) {
newValue = newValue.slice(0, -1);
}
let parts = newValue.split("/");
const parts = newValue.split("/");
if (parts.length > 1) {
newValue = parts.slice(-1).join("/");
}
@@ -194,7 +239,7 @@ const CustomizeStage = ({
disabled={!isAuthenticated}
/>
)}
{cardType === CardTypes.WAKATIME && (
{cardType === CardType.WAKATIME && (
<TextSection
title="WakaTime Username"
description={
@@ -212,86 +257,92 @@ const CustomizeStage = ({
}
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
value={wakatimeUser}
setValue={setWakatimeUser}
onValueChange={setWakatimeUser}
/>
)}
{cardType === CardTypes.STATS && (
{cardType === CardType.STATS && (
<CheckboxSection
title="Show All Stats?"
text="Show all available statistics."
question="Show all stats?"
variable={showAllStats}
setVariable={setShowAllStats}
checked={showAllStats}
onCheckedChange={setShowAllStats}
/>
)}
{cardType === CardTypes.STATS && (
{cardType === CardType.STATS && (
<StatsRankSection
selectedOption={selectedStatsRank}
setSelectedOption={setSelectedStatsRank}
onOptionChange={setSelectedStatsRank}
/>
)}
{cardType === CardTypes.STATS && (
{cardType === CardType.STATS && (
<CheckboxSection
title="Show Icons?"
text="Show icons next to all stats."
question="Show icons?"
variable={showIcons}
setVariable={setShowIcons}
checked={showIcons}
onCheckedChange={setShowIcons}
/>
)}
{cardType === CardTypes.STATS && (
{cardType === CardType.STATS && (
<CheckboxSection
title="Include All Commits?"
text="Count total commits or just commits of the last 365 days."
question="Include all commits?"
variable={includeAllCommits}
setVariable={setIncludeAllCommits}
checked={includeAllCommits}
onCheckedChange={setIncludeAllCommits}
/>
)}
{cardType === CardTypes.TOP_LANGS && (
{cardType === CardType.TOP_LANGS && (
<LanguagesLayoutSection
selectedOption={selectedLanguagesLayout}
setSelectedOption={setSelectedLanguagesLayout}
selectedLanguageLayoutOption={selectedLanguagesLayout}
onLanguageLayoutOptionChange={setSelectedLanguagesLayout}
/>
)}
{cardType === CardTypes.WAKATIME && (
{cardType === CardType.WAKATIME && (
<WakatimeLayoutSection
selectedOption={selectedWakatimeLayout}
setSelectedOption={setSelectedWakatimeLayout}
onOptionChange={setSelectedWakatimeLayout}
/>
)}
{(cardType === CardTypes.TOP_LANGS ||
cardType === CardTypes.WAKATIME) && (
{(cardType === CardType.TOP_LANGS ||
cardType === CardType.WAKATIME) && (
<NumericSection
title="Language Count"
text="Set the number of languages to be shown.<br>Leave empty for default count."
description={
<>
Set the number of languages to be shown.
<br />
Leave empty for default count.
</>
}
value={langsCount}
setValue={setLangsCount}
onValueChange={setLangsCount}
min={1}
max={20}
/>
)}
{cardType === CardTypes.WAKATIME && (
{cardType === CardType.WAKATIME && (
<CheckboxSection
title="Show Percentages?"
text="Show time spent in hours or percentages."
question="Show percentages?"
variable={usePercent}
setVariable={setUsePercent}
checked={usePercent}
onCheckedChange={setUsePercent}
/>
)}
{(cardType === CardTypes.STATS ||
cardType === CardTypes.TOP_LANGS ||
cardType === CardTypes.WAKATIME) && (
{(cardType === CardType.STATS ||
cardType === CardType.TOP_LANGS ||
cardType === CardType.WAKATIME) && (
<CheckboxSection
title="Show Title?"
text="Shows a title at the top of the card."
question="Show title?"
variable={showTitle}
setVariable={setShowTitle}
checked={showTitle}
onCheckedChange={setShowTitle}
/>
)}
{(cardType === CardTypes.STATS || cardType === CardTypes.WAKATIME) && (
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
<TextSection
title="Custom Title"
description={
@@ -303,35 +354,42 @@ const CustomizeStage = ({
}
placeholder='e.g. "My GitHub Stats"'
value={customTitle}
setValue={setCustomTitle}
onValueChange={setCustomTitle}
/>
)}
{(cardType === CardTypes.STATS ||
cardType === CardTypes.TOP_LANGS ||
cardType === CardTypes.WAKATIME) && (
{(cardType === CardType.STATS ||
cardType === CardType.TOP_LANGS ||
cardType === CardType.WAKATIME) && (
<CheckboxSection
title="Enable Animations?"
// text="Enable Animations."
question="enable animations?"
variable={enableAnimations}
setVariable={setEnableAnimations}
checked={enableAnimations}
onCheckedChange={setEnableAnimations}
/>
)}
{(cardType === CardTypes.PIN || cardType === CardTypes.GIST) && (
{(cardType === CardType.PIN || cardType === CardType.GIST) && (
<CheckboxSection
title="Show Owner?"
text="Shows the repo owner's name next to the repo name."
question="Show owner?"
variable={showOwner}
setVariable={setShowOwner}
checked={showOwner}
onCheckedChange={setShowOwner}
/>
)}
{cardType === CardTypes.PIN && (
{cardType === CardType.PIN && (
<NumericSection
title="Description Lines Count"
text="Set the number of lines for the description. Will be clamped between 1 and 3.<br>Leave empty for automatic adjustment."
description={
<>
Set the number of lines for the description. Will be clamped
between 1 and 3.
<br />
Leave empty for automatic adjustment.
</>
}
value={descriptionLines}
setValue={setDescriptionLines}
onValueChange={setDescriptionLines}
min={1}
max={3}
/>
@@ -350,51 +408,9 @@ const 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">
<Image imageSrc={fullSuffix} stage={2} />
<CardImage imageSrc={fullSuffix} stage={2} />
</div>
</div>
</div>
);
};
CustomizeStage.propTypes = {
selectedCard: PropTypes.string.isRequired,
selectedStatsRank: PropTypes.object.isRequired,
setSelectedStatsRank: PropTypes.func.isRequired,
selectedLanguagesLayout: PropTypes.object.isRequired,
setSelectedLanguagesLayout: PropTypes.func.isRequired,
selectedWakatimeLayout: PropTypes.object.isRequired,
setSelectedWakatimeLayout: PropTypes.func.isRequired,
selectedUserId: PropTypes.string.isRequired,
setSelectedUserId: PropTypes.func.isRequired,
repo: PropTypes.string.isRequired,
setRepo: PropTypes.func.isRequired,
gist: PropTypes.string.isRequired,
setGist: PropTypes.func.isRequired,
wakatimeUser: PropTypes.string.isRequired,
setWakatimeUser: PropTypes.func.isRequired,
showTitle: PropTypes.bool.isRequired,
setShowTitle: PropTypes.func.isRequired,
descriptionLines: PropTypes.number.isRequired,
setDescriptionLines: PropTypes.func.isRequired,
showOwner: PropTypes.bool.isRequired,
setShowOwner: PropTypes.func.isRequired,
customTitle: PropTypes.string.isRequired,
setCustomTitle: PropTypes.func.isRequired,
langsCount: PropTypes.number.isRequired,
setLangsCount: PropTypes.func.isRequired,
showIcons: PropTypes.bool.isRequired,
setShowIcons: PropTypes.func.isRequired,
showAllStats: PropTypes.bool.isRequired,
setShowAllStats: PropTypes.func.isRequired,
includeAllCommits: PropTypes.bool.isRequired,
setIncludeAllCommits: PropTypes.func.isRequired,
enableAnimations: PropTypes.bool.isRequired,
setEnableAnimations: PropTypes.func.isRequired,
usePercent: PropTypes.bool.isRequired,
setUsePercent: PropTypes.func.isRequired,
fullSuffix: PropTypes.string.isRequired,
setStage: PropTypes.func.isRequired,
};
export { CustomizeStage };
}
@@ -1,23 +1,30 @@
/* eslint-disable react/no-array-index-key */
import React from "react";
import PropTypes from "prop-types";
import { clsx } from "clsx";
import { saveSvgAsPng } from "save-svg-as-png";
import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { saveSvgAsPng } from "save-svg-as-png";
import { Button } from "../../../components/Generic/Button";
import { Image } from "../../../components/Card/Card";
import { classnames } from "../../../utils";
import { CardImage } from "../../../components/Card/CardImage";
import { HOST } from "../../../constants";
import type { JSX } from "react";
const DisplayStage = ({ filename, link, themeSuffix, guestHint }) => {
interface DisplayStageProps {
filename: string;
link: string;
themeSuffix: string;
guestHint: string | null;
}
export function DisplayStage({
filename,
link,
themeSuffix,
guestHint,
}: DisplayStageProps): JSX.Element {
const downloadPNG = () => {
saveSvgAsPng(
document.getElementById("svgWrapper").shadowRoot.firstElementChild
.firstElementChild,
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
?.firstElementChild as HTMLElement,
`${filename}.png`,
{
scale: 2,
@@ -27,7 +34,7 @@ const DisplayStage = ({ filename, link, themeSuffix, guestHint }) => {
};
const copyMarkdown = () => {
navigator.clipboard.writeText(
void navigator.clipboard.writeText(
`[![GitHub Stats](https://${HOST}/api${themeSuffix})](${link})`,
);
toast.info("Copied to Clipboard!", {
@@ -37,12 +44,11 @@ const DisplayStage = ({ filename, link, themeSuffix, guestHint }) => {
closeOnClick: false,
pauseOnHover: true,
draggable: false,
progress: undefined,
});
};
const copyUrl = () => {
navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
toast.info("Copied to Clipboard!", {
position: "bottom-right",
autoClose: 1500,
@@ -50,7 +56,6 @@ const DisplayStage = ({ filename, link, themeSuffix, guestHint }) => {
closeOnClick: false,
pauseOnHover: true,
draggable: false,
progress: undefined,
});
};
@@ -65,29 +70,35 @@ const DisplayStage = ({ filename, link, themeSuffix, guestHint }) => {
highlight: true,
onClick: copyMarkdown,
},
{ title: "Copy URL", highlight: false, onClick: copyUrl },
{ title: "Download PNG", highlight: false, onClick: downloadPNG },
].map((item, index) => (
{
title: "Copy URL",
highlight: false,
onClick: copyUrl,
},
{
title: "Download PNG",
highlight: false,
onClick: downloadPNG,
},
].map((item) => (
<Button
key={index}
className={classnames(
"m-4 w-60 flex justify-center",
item.highlight
? "bg-blue-500 hover:bg-blue-600 text-white"
: "bg-white hover:bg-gray-100 text-black",
)}
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,
})}
onClick={item.onClick}
>
{item.title}
</Button>
))}
</div>
{guestHint && <div className="pt-10 pl-10 pr-10">{guestHint}</div>}
{!!guestHint && <div className="pt-10 pl-10 pr-10">{guestHint}</div>}
</div>
</div>
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
<Image
<CardImage
imageSrc={`${themeSuffix}&disable_animations=true`}
stage={4}
/>
@@ -95,13 +106,4 @@ const DisplayStage = ({ filename, link, themeSuffix, guestHint }) => {
</div>
</div>
);
};
DisplayStage.propTypes = {
filename: PropTypes.string.isRequired,
link: PropTypes.string.isRequired,
themeSuffix: PropTypes.string.isRequired,
guestHint: PropTypes.string.isRequired,
};
export { DisplayStage };
}
@@ -1,307 +0,0 @@
/* eslint-disable react/no-array-index-key */
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { useDispatch } from "react-redux";
import { Button } from "../../../components/Generic/Button";
import { Image } from "../../../components/Card/Card";
import { classnames } from "../../../utils";
import {
CLIENT_ID,
DEMO_GIST,
DEMO_REPO,
DEMO_USER,
DEMO_WAKATIME_USER,
GITHUB_PRIVATE_AUTH_URL,
GITHUB_PUBLIC_AUTH_URL,
HOST,
} from "../../../constants";
import { FaGithub as GithubIcon } from "react-icons/fa";
import { logout as _logout } from "../../../redux/actions/userActions";
import {
useIsAuthenticated,
usePrivateAccess,
useUserId,
useUserKey,
} from "../../../redux/selectors/userSelectors";
import { deleteAccount } from "../../../api";
const LoginStage = ({ setCurrItem }) => {
const userId = useUserId(null);
const userKey = useUserKey();
const privateAccess = usePrivateAccess();
const isAuthenticated = useIsAuthenticated();
const dispatch = useDispatch();
const [deleteModal, setDeleteModal] = useState(false);
const logout = () => {
dispatch(_logout());
};
const openDeleteModal = () => {
setDeleteModal(true);
};
const closeDeleteModal = () => {
setDeleteModal(false);
};
function useOutsideAlerter(ref, action) {
useEffect(() => {
/**
* Alert if clicked on outside of element
*/
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
action();
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
}
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, closeDeleteModal);
const deleteAccountHandler = async () => {
const success = await deleteAccount(userId, userKey);
if (success) {
logout();
window.location = `https://github.com/settings/connections/applications/${CLIENT_ID}`;
}
};
// Card data
const cards = [
{
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`,
},
];
return (
<div className="h-full flex flex-wrap">
<div className={classnames(deleteModal ? "opacity-25" : "", "md:flex")}>
<div className="lg:block lg:w-3/5 lg:p-8">
<div
className={classnames(
"bg-gray-200 rounded-sm w-full h-full m-auto p-8 shadow",
"lg:h-auto",
)}
>
{isAuthenticated ? (
<>
{/* Access Level Management Buttons */}
<div className="mb-4">
{privateAccess ? (
<div className="flex items-center gap-4">
<a
href={`https://${HOST}/api/downgrade?user_key=${userKey}`}
>
<Button className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100">
<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">
Switch to public access if you prefer not to share
private contributions.
</p>
</div>
) : (
<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">
<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">
Upgrade to include contributions in private repositories
for more complete and accurate stats.
</p>
</div>
)}
</div>
{/* 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"
onClick={openDeleteModal}
>
<span className="xl:text-lg text-red-600">
Delete Account
</span>
</Button>
<p className="text-sm text-gray-600 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>
</div>
{/* 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"
onClick={logout}
>
<span className="xl:text-lg">Log Out</span>
</Button>
<p className="text-sm text-gray-600 flex-1">
Log out from GitHub-Stats-Extended.
</p>
</div>
</>
) : (
<>
{/* User is not logged in - show login options */}
<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">
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">
GitHub Public Access
</span>
</Button>
</a>
<p className="text-sm text-gray-600 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">
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">
GitHub Private Access
</span>
</Button>
</a>
<p className="text-sm text-gray-600 flex-1">
Include contributions from private repositories for more
complete and accurate stats.
</p>
</div>
<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"
onClick={() => setCurrItem(1)}
>
<span className="ml-2 xl:text-lg">Continue as Guest</span>
</Button>
<p className="text-sm text-gray-600 flex-1">
Explore options using sample data. Insert your own username
in the last step.
</p>
</div>
</>
)}
</div>
</div>
<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) => {
const radius = 60;
const centerX = 70;
const startAngle = Math.PI * 0.75; // 135 degrees
const endAngle = Math.PI * 1.25; // 225 degrees
const angle =
startAngle +
(endAngle - startAngle) * (index / (cards.length - 1));
const x = centerX + radius * Math.cos(angle);
return (
<div
key={index}
style={{
left: `${x}%`,
position: "relative",
zoom: "0.5",
marginBottom: "1%",
}}
>
<Image
imageSrc={card.demoImageSrc}
compact={false}
extraClasses=""
stage={0}
/>
</div>
);
})}
</div>
</div>
</div>
{deleteModal && (
<div>
<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"
ref={wrapperRef}
>
<p className="mb-1 text-2xl text-gray-700">Delete Account</p>
<hr />
<br />
<p>
Are you sure you want to delete your account from GitHub
Trends?
</p>
<br />
<div className="flex flex-wrap">
<Button
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
onClick={() => setDeleteModal(false)}
>
Cancel
</Button>
<Button
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
onClick={deleteAccountHandler}
>
Delete Account
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
LoginStage.propTypes = {
setCurrItem: PropTypes.func.isRequired,
};
export { LoginStage };
@@ -0,0 +1,22 @@
import type { JSX } from "react";
import { useIsAuthenticated } from "../../../../redux/selectors/userSelectors";
import { LoginOptions } from "./LoginOptions";
import { LoginAccountManagement } from "./LoginAccountManagement";
interface LoginStageProps {
onContinueAsGuest: () => void;
}
export function LoginStage({
onContinueAsGuest,
}: LoginStageProps): JSX.Element {
const isAuthenticated = useIsAuthenticated();
if (isAuthenticated) {
return <LoginAccountManagement />;
}
return <LoginOptions onContinueAsGuest={onContinueAsGuest} />;
}
@@ -0,0 +1,76 @@
import { useEffect, useRef, type JSX, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Button } from "../../../../components/Generic/Button";
function useOutsideAlerter(
ref: RefObject<HTMLElement | null>,
action: () => void,
) {
useEffect(() => {
/**
* Alert if clicked on outside of element
*/
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
action();
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [action, ref]);
}
interface LoginAccountDeleteModalProps {
onClose: () => void;
onConfirm: () => void;
}
export function LoginAccountDeleteModal(
props: LoginAccountDeleteModalProps,
): JSX.Element {
const { onConfirm, onClose } = props;
const wrapperRef = useRef<HTMLDivElement | null>(null);
useOutsideAlerter(wrapperRef, onClose);
return createPortal(
<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"
ref={wrapperRef}
>
<p className="mb-1 text-2xl text-gray-700">Delete Account</p>
<hr />
<br />
<p>
Are you sure you want to delete your account from GitHub Trends?
</p>
<br />
<div className="flex flex-wrap">
<Button
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
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}
>
Delete Account
</Button>
</div>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,125 @@
import { useCallback, useState, type JSX } from "react";
import { useDispatch } from "react-redux";
import { FaGithub as GithubIcon } from "react-icons/fa";
import {
usePrivateAccess,
useUserId,
useUserKey,
} from "../../../../redux/selectors/userSelectors";
import { deleteAccount } from "../../../../api/user";
import { Button } from "../../../../components/Generic/Button";
import {
CLIENT_ID,
HOST,
GITHUB_PRIVATE_AUTH_URL,
} from "../../../../constants";
import { LoginAccountDeleteModal } from "./LoginAccountDeleteModal";
import { logout } from "../../../../redux/slices/user";
import { LoginBox } from "./LoginBox";
export function LoginAccountManagement(): JSX.Element {
const userId = useUserId();
const userKey = useUserKey();
const privateAccess = usePrivateAccess();
const dispatch = useDispatch();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const openDeleteModal = () => {
setShowDeleteModal(true);
};
const closeDeleteModal = () => {
setShowDeleteModal(false);
};
const handleLogout = useCallback(() => {
dispatch(logout({ userKey: null }));
}, [dispatch]);
const handleAccountDelete = async () => {
const success = await deleteAccount(userId as string, userKey as string);
if (success) {
handleLogout();
window.location.href = `https://github.com/settings/connections/applications/${CLIENT_ID}`;
}
};
return (
<LoginBox isOpaque={showDeleteModal}>
<div className="mb-4">
{privateAccess ? (
<div className="flex items-center gap-4">
<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">
<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">
Switch to public access if you prefer not to share private
contributions.
</p>
</div>
) : (
<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">
<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">
Upgrade to include contributions in private repositories for more
complete and accurate stats.
</p>
</div>
)}
</div>
{/* 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"
onClick={openDeleteModal}
>
<span className="xl:text-lg text-red-600">Delete Account</span>
</Button>
<p className="text-sm text-gray-600 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>
</div>
{/* 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"
onClick={handleLogout}
>
<span className="xl:text-lg">Log Out</span>
</Button>
<p className="text-sm text-gray-600 flex-1">
Log out from GitHub-Stats-Extended.
</p>
</div>
{showDeleteModal && (
<LoginAccountDeleteModal
onClose={closeDeleteModal}
onConfirm={() => {
void handleAccountDelete();
}}
/>
)}
</LoginBox>
);
}
@@ -0,0 +1,28 @@
import type { JSX, ReactNode } from "react";
import clsx from "clsx";
import { LoginBoxDemoCards } from "./LoginBoxDemoCards";
interface LoginBoxProps {
children: ReactNode;
isOpaque?: boolean;
}
export function LoginBox(props: LoginBoxProps): JSX.Element {
const { children, isOpaque = false } = props;
return (
<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">
{children}
</div>
</div>
<LoginBoxDemoCards />
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import type { JSX } from "react";
import { CardImage } from "../../../../components/Card/CardImage";
import {
DEMO_REPO,
DEMO_USER,
DEMO_WAKATIME_USER,
DEMO_GIST,
} from "../../../../constants";
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`,
},
];
function getCardXPosition(cardIndex: number): number {
const radius = 60;
const centerX = 70;
const startAngle = Math.PI * 0.75; // 135 degrees
const endAngle = Math.PI * 1.25; // 225 degrees
const angle =
startAngle + (endAngle - startAngle) * (cardIndex / (cards.length - 1));
const x = centerX + radius * Math.cos(angle);
return x;
}
export function LoginBoxDemoCards(): JSX.Element {
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>
))}
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import type { JSX } from "react";
import { FaGithub as GithubIcon } from "react-icons/fa";
import { Button } from "../../../../components/Generic/Button";
import {
GITHUB_PUBLIC_AUTH_URL,
GITHUB_PRIVATE_AUTH_URL,
} from "../../../../constants";
import { LoginBox } from "./LoginBox";
interface LoginOptionsProps {
onContinueAsGuest: () => void;
}
export function LoginOptions(props: LoginOptionsProps): JSX.Element {
const { onContinueAsGuest } = props;
return (
<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">
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">GitHub Public Access</span>
</Button>
</a>
<p className="text-sm text-gray-600 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">
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">GitHub Private Access</span>
</Button>
</a>
<p className="text-sm text-gray-600 flex-1">
Include contributions from private repositories for more complete and
accurate stats.
</p>
</div>
<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"
onClick={onContinueAsGuest}
>
<span className="ml-2 xl:text-lg">Continue as Guest</span>
</Button>
<p className="text-sm text-gray-600 flex-1">
Explore options using sample data. Insert your own username in the
last step.
</p>
</div>
</LoginBox>
);
}
@@ -1,86 +0,0 @@
/* eslint-disable react/no-array-index-key */
import React from "react";
import PropTypes from "prop-types";
import { Card } from "../../../components/Card/Card";
import { useUserId } from "../../../redux/selectors/userSelectors";
import {
DEMO_GIST,
DEMO_REPO,
DEMO_USER,
DEMO_WAKATIME_USER,
} from "../../../constants";
import { CardTypes } from "../../../utils";
const SelectCardStage = ({ selectedCard, setSelectedCard, setStage }) => {
return (
<div className="w-full flex flex-wrap">
{[
{
title: "GitHub Stats Card",
description: "your overall GitHub statistics",
demoImageSrc: `?username=${useUserId(DEMO_USER)}&include_all_commits=true`,
cardType: CardTypes.STATS,
},
{
title: "Top Languages Card",
description: "your most frequently used languages",
demoImageSrc: `/top-langs?username=${useUserId(DEMO_USER)}&langs_count=4`,
cardType: CardTypes.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}`,
cardType: CardTypes.PIN,
},
{
title: "GitHub Gist Pin",
description:
"pin gists in your GitHub profile using a GitHub profile readme",
demoImageSrc: `/gist?id=${DEMO_GIST}`,
cardType: CardTypes.GIST,
},
{
title: "WakaTime Stats Card",
description: "your coding activity from WakaTime",
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450`,
cardType: CardTypes.WAKATIME,
},
].map((card, index) => (
<button
className="p-2 lg:p-4"
key={index}
type="button"
onClick={() => {
setSelectedCard(card.cardType);
setStage(2);
}}
>
<Card
title={card.title}
description={card.description}
imageSrc={card.demoImageSrc}
selected={selectedCard === card.cardType}
fixedSize="true"
stage={1}
/>
</button>
))}
</div>
);
};
SelectCardStage.propTypes = {
selectedCard: PropTypes.string,
setSelectedCard: PropTypes.func.isRequired,
setStage: PropTypes.func.isRequired,
};
SelectCardStage.defaultProps = {
selectedCard: null,
};
export { SelectCardStage };
@@ -0,0 +1,91 @@
import { Card } from "../../../components/Card/Card";
import { useUserId } from "../../../redux/selectors/userSelectors";
import {
DEMO_GIST,
DEMO_REPO,
DEMO_USER,
DEMO_WAKATIME_USER,
} from "../../../constants";
import { CardType } from "../../../models/CardType";
import { useMemo, type JSX } from "react";
interface SelectCardStageProps {
selectedCardType: CardType;
onCardTypeChange: (cardType: CardType) => void;
}
export function SelectCardStage({
selectedCardType,
onCardTypeChange,
}: SelectCardStageProps): JSX.Element {
const userId = useUserId(DEMO_USER);
const options = useMemo<
Array<{
title: string;
description: string;
demoImageSrc: string;
cardType: CardType;
}>
>(
() => [
{
title: "GitHub Stats Card",
description: "your overall GitHub statistics",
demoImageSrc: `?username=${userId}&include_all_commits=true`,
cardType: CardType.STATS,
},
{
title: "Top Languages Card",
description: "your most frequently used languages",
demoImageSrc: `/top-langs?username=${userId}&langs_count=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}`,
cardType: CardType.PIN,
},
{
title: "GitHub Gist Pin",
description:
"pin gists in your GitHub profile using a GitHub profile readme",
demoImageSrc: `/gist?id=${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`,
cardType: CardType.WAKATIME,
},
],
[userId],
);
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>
))}
</div>
);
}
@@ -1,16 +1,37 @@
/* eslint-disable react/no-array-index-key */
import React from "react";
import PropTypes from "prop-types";
import type { JSX } from "react";
import { Card } from "../../../components/Card/Card";
// @ts-expect-error this will be provided by the npm package
import { themes } from "../../../backend/themes/index";
const ThemeStage = ({ theme, setTheme, setStage, fullSuffix }) => {
// to be removed once npm package has been created
type ThemeData = Record<
string,
{
title_color: string;
icon_color: string;
text_color: string;
bg_color: string;
border_color: string;
}
>;
interface ThemeStageProps {
fullSuffix: string;
theme: string;
onThemeChange: (theme: string) => void;
}
export function ThemeStage({
theme,
fullSuffix,
onThemeChange,
}: ThemeStageProps): JSX.Element {
return (
<>
<div className="flex flex-wrap">
{Object.keys(themes)
{/* Needed until themes is typed correctly and retrieved from npm package */}
{Object.keys(themes as ThemeData)
.filter(
(myTheme) =>
![
@@ -22,14 +43,13 @@ const ThemeStage = ({ theme, setTheme, setStage, fullSuffix }) => {
"holi",
].includes(myTheme),
)
.map((myTheme, index) => (
.map((myTheme) => (
<button
className="p-2 lg:p-4"
key={index}
key={myTheme}
type="button"
onClick={() => {
setTheme(myTheme);
setStage(4);
onThemeChange(myTheme);
}}
>
<Card
@@ -55,13 +75,4 @@ const ThemeStage = ({ theme, setTheme, setStage, fullSuffix }) => {
</div>
</>
);
};
ThemeStage.propTypes = {
theme: PropTypes.string.isRequired,
setTheme: PropTypes.func.isRequired,
setStage: PropTypes.func.isRequired,
fullSuffix: PropTypes.string.isRequired,
};
export { ThemeStage };
}
@@ -1,18 +0,0 @@
export const LOGIN = "LOGIN";
export const LOGOUT = "LOGOUT";
export const SET_USER_ACCESS = "SET_USER_ACCESS";
export function login(userId, userKey) {
return { type: LOGIN, payload: { userId, userKey } };
}
export function logout(userKey = null) {
return { type: LOGOUT, payload: { userKey: userKey } };
}
export function setUserAccess(token, privateAccess) {
return {
type: SET_USER_ACCESS,
payload: { token: token, privateAccess: privateAccess },
};
}
@@ -1,11 +1,12 @@
// eslint-disable-next-line no-unused-vars
const logger = (store) => (next) => (action) => {
import type { Middleware } from "redux";
const loggerMiddleware: Middleware = (_store) => (next) => (action) => {
// console.group(action.type);
// console.info('dispatching', action);
const result = next(action);
// console.log('next state', store.getState());
console.groupEnd();
// console.groupEnd();
return result;
};
export default logger;
export { loggerMiddleware };
@@ -1,5 +0,0 @@
import { combineReducers } from "redux";
import user from "./user";
export default combineReducers({ user });
-43
View File
@@ -1,43 +0,0 @@
import * as types from "../actions/userActions";
const initialState = {
userId: JSON.parse(localStorage.getItem("userId")) || null,
userKey: JSON.parse(localStorage.getItem("userKey")) || null,
token: null,
privateAccess: null,
};
export default (state = initialState, action) => {
switch (action.type) {
case types.LOGIN:
localStorage.setItem("userId", JSON.stringify(action.payload.userId));
localStorage.setItem("userKey", JSON.stringify(action.payload.userKey));
return {
...state,
userId: action.payload.userId,
userKey: action.payload.userKey,
};
case types.LOGOUT:
if (
action.payload.userKey !== null &&
action.payload.userKey !== JSON.parse(localStorage.getItem("userKey"))
) {
return state;
}
localStorage.clear();
return {
userId: null,
userKey: null,
token: null,
privateAccess: null,
};
case types.SET_USER_ACCESS:
return {
...state,
token: action.payload.token,
privateAccess: action.payload.privateAccess,
};
default:
return state;
}
};
@@ -1,24 +0,0 @@
import { useSelector } from "react-redux";
export const useUserId = (fallbackUsername) => {
return useSelector((state) => state.user.userId) || fallbackUsername;
};
export const useIsAuthenticated = () => {
return useSelector((state) => {
const userId = state.user.userId;
return userId && userId.length > 0;
});
};
export const usePrivateAccess = () => {
return useSelector((state) => state.user.privateAccess);
};
export const useUserKey = () => {
return useSelector((state) => state.user.userKey);
};
export const useUserToken = () => {
return useSelector((state) => state.user.token);
};
@@ -0,0 +1,29 @@
import { useSelector } from "react-redux";
import type { StoreState } from "../store";
export const useUserId = <TUserName extends string | undefined>(
fallbackUsername?: TUserName,
): TUserName => {
const storeValue = useSelector((state: StoreState) => state.user.userId);
return (storeValue || fallbackUsername || null) as TUserName;
};
export const useIsAuthenticated = (): boolean => {
return useSelector((state: StoreState) => {
const userId = state.user.userId;
return !!(userId && userId.length > 0);
});
};
export const usePrivateAccess = (): string | null => {
return useSelector((state: StoreState) => state.user.privateAccess);
};
export const useUserKey = (): string | null => {
return useSelector((state: StoreState) => state.user.userKey);
};
export const useUserToken = (): string | null => {
return useSelector((state: StoreState) => state.user.token);
};
+74
View File
@@ -0,0 +1,74 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
/**
* @public
* Used to snooze knip report.
* This export is unused but is required to properly perform infer types on the store:
* error TS4023: Exported variable 'store' has or is using name 'UserState' from external module "src/redux/slices/user" but cannot be named.
*/
export interface UserState {
userId: string | null;
userKey: string | null;
token: string | null;
privateAccess: string | null;
}
function getFromLocalStorage(key: string): string | null {
const storageValue = localStorage.getItem(key);
if (!storageValue) {
return null;
}
return (JSON.parse(storageValue) as string) || null;
}
const initialState: UserState = {
userId: getFromLocalStorage("userId"),
userKey: getFromLocalStorage("userKey"),
token: null,
privateAccess: null,
};
const userSlice = createSlice({
name: "user",
initialState,
reducers: {
login: (
state,
action: PayloadAction<{ userId: string; userKey: string }>,
) => {
const { userId, userKey } = action.payload;
localStorage.setItem("userId", JSON.stringify(userId));
localStorage.setItem("userKey", JSON.stringify(userKey));
state.userId = userId;
state.userKey = userKey;
},
logout: (state, action: PayloadAction<{ userKey: string | null }>) => {
const { userKey } = action.payload;
if (
userKey !== null &&
userKey !== JSON.parse(localStorage.getItem("userKey") as string)
) {
return;
}
localStorage.clear();
state.userId = null;
state.userKey = null;
state.token = null;
state.privateAccess = null;
},
setUserAccess: (
state,
action: PayloadAction<{ token: string; privateAccess: string }>,
) => {
const { token, privateAccess } = action.payload;
state.token = token;
state.privateAccess = privateAccess;
},
},
});
export const { login, logout, setUserAccess } = userSlice.actions;
export default userSlice.reducer;
-24
View File
@@ -1,24 +0,0 @@
import { applyMiddleware, createStore, compose } from "redux";
import loggerMiddleware from "./logger";
import rootReducer from "./reducers";
import { USE_LOGGER } from "../constants";
/**
* @param {unknown?} initialState store initial state
* @returns {import('redux').Store} store
*/
export default function configureStore(initialState) {
let middlewares = [];
if (USE_LOGGER) {
middlewares = [loggerMiddleware];
}
const middlewareEnhancer = applyMiddleware(...middlewares);
const enhancers = [middlewareEnhancer];
const composedEnhancers = compose(...enhancers);
const store = createStore(rootReducer, initialState, composedEnhancers);
return store;
}
+23
View File
@@ -0,0 +1,23 @@
import { configureStore } from "@reduxjs/toolkit";
import { USE_LOGGER } from "../constants";
import { loggerMiddleware } from "./logger";
import user from "./slices/user";
const store = configureStore({
reducer: {
user,
},
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware();
if (USE_LOGGER) {
return middleware.concat(loggerMiddleware);
}
return middleware;
},
});
export { store };
export type StoreState = ReturnType<(typeof store)["getState"]>;
-15
View File
@@ -1,15 +0,0 @@
/**
* @param {Array<string>} args list of class names
* @returns string classname attribute
*/
export function classnames(...args) {
return args.join(" ");
}
export const CardTypes = {
STATS: "stats",
TOP_LANGS: "top-langs",
PIN: "pin",
GIST: "gist",
WAKATIME: "wakatime",
};
@@ -2,17 +2,22 @@ import axios from "axios";
import { HOST } from "./constants";
// See https://github.com/stats-organization/github-stats-extended/pull/27#discussion_r2712184285
// eslint-disable-next-line no-unused-vars
const fetchWakatimeStats = async ({ username, api_domain }) => {
const fetchWakatimeStats = async ({
username,
api_domain: _,
}: {
username: string;
api_domain: string;
}): Promise<unknown> => {
if (!username) {
throw new Error("missing parameter: username");
}
const { data } = await axios.get(
const res = await axios.get<unknown>(
`https://${HOST}/api/wakatime-proxy?username=${username}`,
);
return data;
return res.data;
};
export { fetchWakatimeStats };
+4 -2
View File
@@ -1,5 +1,7 @@
import daisyui from "daisyui";
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
screens: {
@@ -16,5 +18,5 @@ module.exports = {
},
},
},
plugins: [require("daisyui")],
plugins: [daisyui],
};
+12
View File
@@ -0,0 +1,12 @@
{
"extends": ["../../tsconfig.base.json"],
"include": ["src", "src/**/*.json", "vite.config.ts"],
"compilerOptions": {
"lib": ["DOM"],
"composite": true,
"module": "esnext",
"moduleResolution": "bundler",
"outDir": "./build-ts",
"resolveJsonModule": true
}
}
+21 -6
View File
@@ -22,12 +22,27 @@ export default defineConfig({
}),
react(),
// mock pg (postgres) package in the browser to avoid runtime errors
{
name: "empty-pg-package",
resolveId(id) {
if (id === "pg") {
return id;
}
return undefined;
},
load(id) {
if (id === "pg") {
return "export default {}";
}
return undefined;
},
},
],
build: {
outDir: "build",
rollupOptions: {
external: ["pg"],
},
/** @todo use chunks to split bundle? */
chunkSizeWarningLimit: 800,
},
@@ -41,14 +56,14 @@ export default defineConfig({
find: "dotenv",
replacement: path.resolve(
import.meta.dirname,
"src/dotenv-browser-stub.js",
"src/dotenv-browser-stub.ts",
),
},
{
find: "./src/fetchers/wakatime.js",
find: "../src/fetchers/wakatime.js",
replacement: path.resolve(
import.meta.dirname,
"src/wakatime-override.js",
"src/wakatime-override.ts",
),
},
],
+39 -4
View File
@@ -7,6 +7,7 @@ import jsdoc from "eslint-plugin-jsdoc";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import { includeIgnoreFile } from "@eslint/compat";
import tseslint from "typescript-eslint";
const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url));
@@ -25,8 +26,6 @@ export default defineConfig(
},
plugins: {
jsdoc,
react,
"react-hooks": reactHooks,
},
rules: {
"no-unexpected-multiline": "error",
@@ -64,7 +63,6 @@ export default defineConfig(
"no-this-before-super": "error",
"object-shorthand": ["warn"],
"no-mixed-spaces-and-tabs": "warn",
"no-multiple-empty-lines": "warn",
"no-negated-condition": "warn",
"no-unneeded-ternary": "warn",
"keyword-spacing": [
@@ -80,6 +78,43 @@ export default defineConfig(
"jsdoc/require-jsdoc": "warn",
},
},
{
files: ["**/*.{d.ts,ts,tsx}"],
ignores: ["apps/backend/**"],
extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylistic],
rules: {
"@typescript-eslint/array-type": ["error", { default: "generic" }],
"@typescript-eslint/restrict-template-expressions": [
"error",
{
allowAny: false,
allowBoolean: true, // for query parameters
allowNever: false,
allowNullish: false,
allowNumber: true,
allowRegExp: false,
},
],
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
},
],
// We don't need this we have typescript
"jsdoc/require-returns": "off",
"jsdoc/require-returns-description": "off",
"jsdoc/require-param-description": "off",
"jsdoc/require-jsdoc": "off",
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
{
files: ["apps/backend/**/*.{js}"],
languageOptions: {
@@ -89,7 +124,7 @@ export default defineConfig(
},
},
{
files: ["apps/frontend/**/*.{js,jsx}"],
files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"],
plugins: {
react,
"react-hooks": reactHooks,
+3 -2
View File
@@ -20,9 +20,10 @@
"apps/frontend": {
"entry": [
"src/index.jsx",
"src/dotenv-browser-stub.js",
"src/wakatime-override.js"
"src/dotenv-browser-stub.ts", // referenced by vite.config.ts
"src/wakatime-override.ts"
],
"ignoreDependencies": [
// below dependencies are added because backend folder is copied inside frontend folder,
// so some of his dependencies must be present here
+4 -2
View File
@@ -15,7 +15,8 @@
"knip": "5.81.0",
"lint-staged": "16.2.7",
"prettier": "3.7.4",
"typescript": "5.9.3"
"typescript": "5.9.3",
"typescript-eslint": "8.53.1"
},
"scripts": {
"prepare": "husky",
@@ -23,7 +24,8 @@
"format:check": "prettier --check .",
"lint": "eslint",
"lint:fix": "eslint --fix",
"lint:knip": "knip"
"lint:knip": "knip",
"typecheck": "tsc --build --noEmit"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,css,json,jsonc,yaml,yml}": "prettier --write"
+291 -87
View File
@@ -44,6 +44,9 @@ importers:
typescript:
specifier: 5.9.3
version: 5.9.3
typescript-eslint:
specifier: 8.53.1
version: 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
apps/backend:
dependencies:
@@ -114,6 +117,9 @@ importers:
apps/frontend:
dependencies:
'@reduxjs/toolkit':
specifier: 2.11.2
version: 2.11.2(react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
axios:
specifier: ^1
version: 1.13.2
@@ -129,14 +135,11 @@ importers:
github-username-regex:
specifier: ^1.0.0
version: 1.0.0
prop-types:
specifier: ^15.8.1
version: 15.8.1
react:
specifier: ^18.2.0
specifier: 18.3.1
version: 18.3.1
react-dom:
specifier: ^18.2.0
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-icons:
specifier: ^4.11.0
@@ -145,11 +148,8 @@ importers:
specifier: ^3.3.1
version: 3.5.0(react@18.3.1)
react-redux:
specifier: ^8.1.3
version: 8.1.3(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1)
react-router-dom:
specifier: ^6.18.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 9.2.0
version: 9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1)
react-spinners:
specifier: ^0.13.8
version: 0.13.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -157,8 +157,8 @@ importers:
specifier: ^9.1.3
version: 9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
redux:
specifier: ^4.2.1
version: 4.2.1
specifier: 5.0.1
version: 5.0.1
save-svg-as-png:
specifier: ^1.4.17
version: 1.4.17
@@ -169,12 +169,24 @@ importers:
specifier: ^1.2.5
version: 1.2.5
devDependencies:
'@types/react':
specifier: 18.3.27
version: 18.3.27
'@types/react-dom':
specifier: 18.3.7
version: 18.3.7(@types/react@18.3.27)
'@types/uuid':
specifier: 9.0.8
version: 9.0.8
'@vitejs/plugin-react-swc':
specifier: 4.2.2
version: 4.2.2(vite@7.3.1(@types/node@25.0.3)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))
autoprefixer:
specifier: ^10.4.16
version: 10.4.23(postcss@8.5.6)
clsx:
specifier: 2.1.1
version: 2.1.1
postcss:
specifier: ^8.4.31
version: 8.5.6
@@ -971,9 +983,16 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-beta.47':
resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==}
@@ -1147,6 +1166,12 @@ packages:
'@sinonjs/fake-timers@13.0.5':
resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/core-darwin-arm64@1.15.8':
resolution: {integrity: sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==}
engines: {node: '>=10'}
@@ -1267,11 +1292,6 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/hoist-non-react-statics@3.3.7':
resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
peerDependencies:
'@types/react': '*'
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -1296,6 +1316,11 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
'@types/react': ^18.0.0
'@types/react@18.3.27':
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
@@ -1305,8 +1330,11 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/use-sync-external-store@0.0.3':
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -1314,10 +1342,69 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
'@typescript-eslint/eslint-plugin@8.53.1':
resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.53.1
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.53.1':
resolution: {integrity: sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.53.1':
resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.53.1':
resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.53.1':
resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.53.1':
resolution: {integrity: sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.52.0':
resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.53.1':
resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.53.1':
resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.53.1':
resolution: {integrity: sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.53.1':
resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -1771,6 +1858,10 @@ packages:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -2449,9 +2540,6 @@ packages:
hmac-drbg@1.0.1:
resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
@@ -2504,6 +2592,13 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3508,40 +3603,18 @@ packages:
peerDependencies:
react: '>=16.8.0'
react-redux@8.1.3:
resolution: {integrity: sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^16.8 || ^17.0 || ^18.0
'@types/react-dom': ^16.8 || ^17.0 || ^18.0
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
react-native: '>=0.59'
redux: ^4 || ^5.0.0-beta.0
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
react-dom:
optional: true
react-native:
optional: true
redux:
optional: true
react-router-dom@6.30.3:
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.3:
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-spinners@0.13.8:
resolution: {integrity: sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==}
peerDependencies:
@@ -3576,8 +3649,13 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
redux@4.2.1:
resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
@@ -3591,6 +3669,9 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reserved-identifiers@1.2.0:
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
engines: {node: '>=18'}
@@ -3978,6 +4059,12 @@ packages:
try@1.0.3:
resolution: {integrity: sha512-AHA8khVCII6zKyRkyPo6pRwoR9v5jb7QFw6e5avtaVSkxVfaEucYIo06xnwB+pJaEarfYNbs7W3Vq+LZLZiWyA==}
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -4037,6 +4124,13 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -5111,7 +5205,17 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@remix-run/router@1.23.2': {}
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.3
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 18.3.1
react-redux: 9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-beta.47': {}
@@ -5218,6 +5322,10 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/core-darwin-arm64@1.15.8':
optional: true
@@ -5332,11 +5440,6 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/hoist-non-react-statics@3.3.7(@types/react@18.3.27)':
dependencies:
'@types/react': 18.3.27
hoist-non-react-statics: 3.3.2
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -5364,6 +5467,10 @@ snapshots:
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.27)':
dependencies:
'@types/react': 18.3.27
'@types/react@18.3.27':
dependencies:
'@types/prop-types': 15.7.15
@@ -5373,7 +5480,9 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
'@types/use-sync-external-store@0.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@9.0.8': {}
'@types/yargs-parser@21.0.3': {}
@@ -5381,8 +5490,99 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.53.1
'@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.53.1
eslint: 9.39.2(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.53.1
'@typescript-eslint/types': 8.53.1
'@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.53.1
debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.53.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
'@typescript-eslint/types': 8.53.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.53.1':
dependencies:
'@typescript-eslint/types': 8.53.1
'@typescript-eslint/visitor-keys': 8.53.1
'@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.53.1
'@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.52.0': {}
'@typescript-eslint/types@8.53.1': {}
'@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.53.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
'@typescript-eslint/types': 8.53.1
'@typescript-eslint/visitor-keys': 8.53.1
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.53.1
'@typescript-eslint/types': 8.53.1
'@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.53.1':
dependencies:
'@typescript-eslint/types': 8.53.1
eslint-visitor-keys: 4.2.1
'@ungap/structured-clone@1.3.0': {}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
@@ -5886,6 +6086,8 @@ snapshots:
clsx@1.2.1: {}
clsx@2.1.1: {}
co@4.6.0: {}
collect-v8-coverage@1.0.3: {}
@@ -6739,10 +6941,6 @@ snapshots:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
@@ -6793,6 +6991,10 @@ snapshots:
ignore@5.3.2: {}
ignore@7.0.5: {}
immer@11.1.3: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -8009,31 +8211,14 @@ snapshots:
dependencies:
react: 18.3.1
react-redux@8.1.3(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1):
react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1):
dependencies:
'@babel/runtime': 7.28.4
'@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.27)
'@types/use-sync-external-store': 0.0.3
hoist-non-react-statics: 3.3.2
'@types/use-sync-external-store': 0.0.6
react: 18.3.1
react-is: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
react-dom: 18.3.1(react@18.3.1)
redux: 4.2.1
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.3(react@18.3.1)
react-router@6.30.3(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.2
react: 18.3.1
redux: 5.0.1
react-spinners@0.13.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -8079,9 +8264,11 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
redux@4.2.1:
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
'@babel/runtime': 7.28.4
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
@@ -8105,6 +8292,8 @@ snapshots:
require-directory@2.1.1: {}
reselect@5.1.1: {}
reserved-identifiers@1.2.0: {}
resolve-cwd@3.0.0:
@@ -8608,6 +8797,10 @@ snapshots:
try@1.0.3: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-interface-checker@0.1.13: {}
ts-node@10.9.2(@swc/core@1.15.8)(@types/node@25.0.3)(typescript@5.9.3):
@@ -8685,6 +8878,17 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
unbox-primitive@1.1.0:
+32
View File
@@ -0,0 +1,32 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// Type Checking
"strict": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Modules
"module": "nodenext",
"target": "esnext",
"types": [],
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
// Interop Constraints
"verbatimModuleSyntax": true,
"isolatedModules": true,
// Language and Environment
"jsx": "react-jsx",
// Completeness
"skipLibCheck": true
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{
"path": "./apps/frontend"
}
]
}