diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e091116..ff0b186d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,6 @@ jobs: - name: Lint (knip) run: pnpm run lint:knip + + - name: Typecheck + run: pnpm run typecheck diff --git a/.gitignore b/.gitignore index 5ae9ed25..a3ec671d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ apps/frontend/.env apps/frontend/src/backend apps/frontend/build +build-ts +tsconfig.tsbuildinfo + # IDE .idea/ .vscode/* diff --git a/.vscode/settings.json b/.vscode/settings.json index f8c22a76..52099c7f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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"] } diff --git a/apps/backend/src/fetchers/stats.js b/apps/backend/src/fetchers/stats.js index 535f9d7e..557f0afd 100644 --- a/apps/backend/src/fetchers/stats.js +++ b/apps/backend/src/fetchers/stats.js @@ -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; } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f6d1d775..0535d4ac 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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": { diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/frontend/src/api/index.js b/apps/frontend/src/api/index.js deleted file mode 100644 index a5d65dfb..00000000 --- a/apps/frontend/src/api/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { authenticate, getUserMetadata, deleteAccount } from "./user"; - -export { authenticate, getUserMetadata, deleteAccount }; diff --git a/apps/frontend/src/api/user.js b/apps/frontend/src/api/user.ts similarity index 64% rename from apps/frontend/src/api/user.js rename to apps/frontend/src/api/user.ts index 0eb9441d..4fe73bb2 100644 --- a/apps/frontend/src/api/user.js +++ b/apps/frontend/src/api/user.ts @@ -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 => { 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 => { try { const fullUrl = `https://${HOST}/api/user-access?user_key=${userKey}`; - const result = await axios.get(fullUrl); + const result = await axios.get(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 => { try { const fullUrl = `https://${HOST}/api/delete-user?user_key=${userKey}`; const result = await axios.get(fullUrl); diff --git a/apps/frontend/src/axios-override.js b/apps/frontend/src/axios-override.ts similarity index 84% rename from apps/frontend/src/axios-override.js rename to apps/frontend/src/axios-override.ts index 83b06694..5b716ccf 100644 --- a/apps/frontend/src/axios-override.js +++ b/apps/frontend/src/axios-override.ts @@ -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( + data: TData, + config: TConfig, +): Promise<{ + data: TData; + status: 200; + statusText: string; + headers: Record; + request: Record; + 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); } diff --git a/apps/frontend/src/components/Card/Card.jsx b/apps/frontend/src/components/Card/Card.jsx deleted file mode 100644 index 9e1f0d52..00000000 --- a/apps/frontend/src/components/Card/Card.jsx +++ /dev/null @@ -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 ( -
- -
- ); -}; - -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 ( -
-

{title}

-

{description}

- -
- ); -}; - -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, -}; diff --git a/apps/frontend/src/components/Card/Card.tsx b/apps/frontend/src/components/Card/Card.tsx new file mode 100644 index 00000000..833720be --- /dev/null +++ b/apps/frontend/src/components/Card/Card.tsx @@ -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 ( +
+

{title}

+

{description}

+ +
+ ); +}; diff --git a/apps/frontend/src/components/Card/CardImage.tsx b/apps/frontend/src/components/Card/CardImage.tsx new file mode 100644 index 00000000..e09c97ea --- /dev/null +++ b/apps/frontend/src/components/Card/CardImage.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/apps/frontend/src/components/Card/SVG.jsx b/apps/frontend/src/components/Card/SvgInline.tsx similarity index 60% rename from apps/frontend/src/components/Card/SVG.jsx rename to apps/frontend/src/components/Card/SvgInline.tsx index 3ba06b6a..c9b37377 100644 --- a/apps/frontend/src/components/Card/SVG.jsx +++ b/apps/frontend/src/components/Card/SvgInline.tsx @@ -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(null); const [loaded, setLoaded] = useState(false); - const containerRef = useRef(null); + const containerRef = useRef(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(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 ; } // maximum dimensions of cards in SelectCard stage @@ -100,21 +117,5 @@ const SvgInline = (props) => { } // Render a container div for the shadow DOM - return
; -}; - -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
; +} diff --git a/apps/frontend/src/components/Generic/Button.jsx b/apps/frontend/src/components/Generic/Button.jsx deleted file mode 100644 index 802dc57b..00000000 --- a/apps/frontend/src/components/Generic/Button.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; - -import { classnames } from "../../utils"; - -const Button = (props) => { - return ( - - ); -}; - -Button.propTypes = { - className: PropTypes.string, - children: PropTypes.node.isRequired, -}; - -Button.defaultProps = { - className: "", -}; - -export { Button }; diff --git a/apps/frontend/src/components/Generic/Button.tsx b/apps/frontend/src/components/Generic/Button.tsx new file mode 100644 index 00000000..f88426eb --- /dev/null +++ b/apps/frontend/src/components/Generic/Button.tsx @@ -0,0 +1,22 @@ +import clsx from "clsx"; +import type { HTMLProps, JSX, ReactNode } from "react"; + +interface ButtonProps extends HTMLProps { + children: ReactNode; +} + +export function Button(props: ButtonProps): JSX.Element { + const { className, children, ...rest } = props; + return ( + + ); +} diff --git a/apps/frontend/src/components/Generic/Checkbox.jsx b/apps/frontend/src/components/Generic/Checkbox.jsx deleted file mode 100644 index 657e3bce..00000000 --- a/apps/frontend/src/components/Generic/Checkbox.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; - -const Checkbox = ({ question, variable, setVariable, disabled }) => { - return ( -
setVariable(!variable)} - role="button" - > - setVariable(!variable)} - /> - {question} -
- ); -}; - -Checkbox.propTypes = { - question: PropTypes.string.isRequired, - variable: PropTypes.bool.isRequired, - setVariable: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; - -Checkbox.defaultProps = { - disabled: false, -}; - -export { Checkbox }; diff --git a/apps/frontend/src/components/Generic/Checkbox.tsx b/apps/frontend/src/components/Generic/Checkbox.tsx new file mode 100644 index 00000000..b41f6f25 --- /dev/null +++ b/apps/frontend/src/components/Generic/Checkbox.tsx @@ -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 ( + + ); +} diff --git a/apps/frontend/src/components/Generic/Input.jsx b/apps/frontend/src/components/Generic/Input.jsx deleted file mode 100644 index 2d2260e1..00000000 --- a/apps/frontend/src/components/Generic/Input.jsx +++ /dev/null @@ -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 ( - - ); -}; - -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 }; diff --git a/apps/frontend/src/components/Generic/Select.tsx b/apps/frontend/src/components/Generic/Select.tsx new file mode 100644 index 00000000..9f80370e --- /dev/null +++ b/apps/frontend/src/components/Generic/Select.tsx @@ -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; + selectedOption: SelectOption; + + className?: string; + disabled?: boolean; + + onOptionChange: (option: SelectOption) => void; +} + +export function Select({ + options, + selectedOption, + onOptionChange, + disabled, + className, +}: SelectProps): JSX.Element { + return ( + + ); +} diff --git a/apps/frontend/src/components/Home/CheckboxSection.jsx b/apps/frontend/src/components/Home/CheckboxSection.jsx deleted file mode 100644 index 76ca9f78..00000000 --- a/apps/frontend/src/components/Home/CheckboxSection.jsx +++ /dev/null @@ -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 ( -
- {text &&

} - -

- ); -}; - -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 }; diff --git a/apps/frontend/src/components/Home/CheckboxSection.tsx b/apps/frontend/src/components/Home/CheckboxSection.tsx new file mode 100644 index 00000000..89a95cea --- /dev/null +++ b/apps/frontend/src/components/Home/CheckboxSection.tsx @@ -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 ( +
+ {text &&

{text}

} + +
+ ); +} diff --git a/apps/frontend/src/components/Home/LanguagesLayoutSection.jsx b/apps/frontend/src/components/Home/LanguagesLayoutSection.jsx deleted file mode 100644 index c64f859a..00000000 --- a/apps/frontend/src/components/Home/LanguagesLayoutSection.jsx +++ /dev/null @@ -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 ( -
-

Select a card layout.

- -
- ); -}; - -LanguagesLayoutSection.propTypes = { - selectedOption: PropTypes.object.isRequired, - setSelectedOption: PropTypes.func.isRequired, -}; - -export { LanguagesLayoutSection }; diff --git a/apps/frontend/src/components/Home/LanguagesLayoutSection.tsx b/apps/frontend/src/components/Home/LanguagesLayoutSection.tsx new file mode 100644 index 00000000..97c81400 --- /dev/null +++ b/apps/frontend/src/components/Home/LanguagesLayoutSection.tsx @@ -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 = [ + 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 ( +
+

Select a card layout.

+ setInternalValue(e.target.value)} - min={min} - max={max} - step={step} - disabled={disabled} - placeholder={placeholder} - /> -
- ); -}; - -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 }; diff --git a/apps/frontend/src/components/Home/NumericSection.tsx b/apps/frontend/src/components/Home/NumericSection.tsx new file mode 100644 index 00000000..b0e1bce4 --- /dev/null +++ b/apps/frontend/src/components/Home/NumericSection.tsx @@ -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(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 ( +
+

{description}

+ { + setInternalValue(e.target.value); + }} + min={min} + max={max} + step={step} + disabled={disabled} + placeholder={placeholder} + /> +
+ ); +} diff --git a/apps/frontend/src/components/Home/Progress.jsx b/apps/frontend/src/components/Home/Progress.jsx deleted file mode 100644 index 069eca09..00000000 --- a/apps/frontend/src/components/Home/Progress.jsx +++ /dev/null @@ -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 ( - - ); -}; - -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 ( -
- setCurrItem(Math.max(currItem - 1, 0))} - /> -
- {items.map((item, index) => { - return ( - = index} - isActive={currItem === index} - onClick={() => setCurrItem(index)} - /> - ); - })} -
- setCurrItem(Math.min(currItem + 1, items.length - 1))} - /> -
- ); -}; - -ProgressBar.propTypes = { - items: PropTypes.array.isRequired, - currItem: PropTypes.number.isRequired, - setCurrItem: PropTypes.func.isRequired, -}; - -export { ProgressBar }; diff --git a/apps/frontend/src/components/Home/Progress.tsx b/apps/frontend/src/components/Home/Progress.tsx new file mode 100644 index 00000000..f296c333 --- /dev/null +++ b/apps/frontend/src/components/Home/Progress.tsx @@ -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; +} + +function ProgressSection({ + num, + item, + passed, + isActive, + onClick, +}: ProgressSectionProps): JSX.Element { + return ( + + ); +} + +interface ProgressBarProps { + items: Array; + 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 ( +
+ { + onItemClick(Math.max(currItemIndex - 1, 0)); + }} + /> +
+ {items.map((item, index) => { + return ( + = index} + isActive={currItemIndex === index} + onClick={() => { + onItemClick(index); + }} + /> + ); + })} +
+ { + onItemClick(Math.min(currItemIndex + 1, items.length - 1)); + }} + /> +
+ ); +} diff --git a/apps/frontend/src/components/Home/Section.jsx b/apps/frontend/src/components/Home/Section.tsx similarity index 65% rename from apps/frontend/src/components/Home/Section.jsx rename to apps/frontend/src/components/Home/Section.tsx index 6255e3cb..cf4e8d91 100644 --- a/apps/frontend/src/components/Home/Section.jsx +++ b/apps/frontend/src/components/Home/Section.tsx @@ -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 (
@@ -15,22 +19,10 @@ const Section = (props) => {

- {props.title} + {title}

- {props.children} + {children}
); -}; - -Section.propTypes = { - title: PropTypes.string, - children: PropTypes.node, -}; - -Section.defaultProps = { - title: "Test", - children:

This is a test!

, -}; - -export { Section }; +} diff --git a/apps/frontend/src/components/Home/StatsRankSection.jsx b/apps/frontend/src/components/Home/StatsRankSection.jsx deleted file mode 100644 index f523c5f6..00000000 --- a/apps/frontend/src/components/Home/StatsRankSection.jsx +++ /dev/null @@ -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 ( -
-

Select a progress style.

- -
- ); -}; - -StatsRankSection.propTypes = { - selectedOption: PropTypes.object.isRequired, - setSelectedOption: PropTypes.func.isRequired, -}; - -export { StatsRankSection }; diff --git a/apps/frontend/src/components/Home/StatsRankSection.tsx b/apps/frontend/src/components/Home/StatsRankSection.tsx new file mode 100644 index 00000000..79d5ec41 --- /dev/null +++ b/apps/frontend/src/components/Home/StatsRankSection.tsx @@ -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 = [ + 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 ( +
+

Select a progress style.

+ setInternalValue(e.target.value)} - disabled={disabled} - placeholder={placeholder} - onPaste={onPaste} - /> -
- ); -}; - -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 }; diff --git a/apps/frontend/src/components/Home/TextSection.tsx b/apps/frontend/src/components/Home/TextSection.tsx new file mode 100644 index 00000000..8a04caa8 --- /dev/null +++ b/apps/frontend/src/components/Home/TextSection.tsx @@ -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; +} + +export function TextSection({ + title, + description, + value, + onValueChange, + disabled = false, + placeholder, + onPaste, +}: TextSectionProps): JSX.Element { + const [internalValue, setInternalValue] = useState(value); + const debounceTimeout = useRef(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 ( +
+

{description}

+ { + setInternalValue(e.target.value); + }} + disabled={disabled} + placeholder={placeholder} + onPaste={onPaste} + /> +
+ ); +} diff --git a/apps/frontend/src/components/Home/WakatimeLayoutSection.jsx b/apps/frontend/src/components/Home/WakatimeLayoutSection.jsx deleted file mode 100644 index 56bdf26a..00000000 --- a/apps/frontend/src/components/Home/WakatimeLayoutSection.jsx +++ /dev/null @@ -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 ( -
-

Select a card layout.

- -
- ); -}; - -WakatimeLayoutSection.propTypes = { - selectedOption: PropTypes.object.isRequired, - setSelectedOption: PropTypes.func.isRequired, -}; - -export default WakatimeLayoutSection; diff --git a/apps/frontend/src/components/Home/WakatimeLayoutSection.tsx b/apps/frontend/src/components/Home/WakatimeLayoutSection.tsx new file mode 100644 index 00000000..13ce0b3d --- /dev/null +++ b/apps/frontend/src/components/Home/WakatimeLayoutSection.tsx @@ -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 = [ + 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 ( +
+

Select a card layout.

+