feat(frontend): Login - split components into multiple subcomponents (#42)

- closes #40

I have divided Login into many subcomponents.
I think that each component has a name that clearly states its purpose.
This commit is contained in:
Martin
2026-02-03 09:56:14 +01:00
committed by GitHub
8 changed files with 374 additions and 308 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ import type { StageIndex } from "../../models/Stage";
import { CustomizeStage } from "./stages/Customize";
import { DisplayStage } from "./stages/Display";
import { LoginStage } from "./stages/Login";
import { LoginStage } from "./stages/Login/Login";
import { SelectCardStage } from "./stages/SelectCard";
import { ThemeStage } from "./stages/Theme";
@@ -358,7 +358,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
</div>
{stage === 0 && (
<LoginStage
onContinueAsGuestClick={() => {
onContinueAsGuest={() => {
setStage(1);
}}
/>
@@ -1,306 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX, RefObject } from "react";
import clsx from "clsx";
import { useDispatch } from "react-redux";
import { Button } from "../../../components/Generic/Button";
import { CardImage } from "../../../components/Card/CardImage";
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 } from "../../../redux/slices/user";
import {
useIsAuthenticated,
usePrivateAccess,
useUserId,
useUserKey,
} from "../../../redux/selectors/userSelectors";
import { deleteAccount } from "../../../api/user";
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 LoginStageProps {
onContinueAsGuestClick: () => void;
}
export function LoginStage({
onContinueAsGuestClick,
}: LoginStageProps): JSX.Element {
const userId = useUserId();
const userKey = useUserKey();
const privateAccess = usePrivateAccess();
const isAuthenticated = useIsAuthenticated();
const dispatch = useDispatch();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleLogout = useCallback(() => {
dispatch(logout({ userKey: null }));
}, [dispatch]);
const openDeleteModal = () => {
setShowDeleteModal(true);
};
const closeDeleteModal = () => {
setShowDeleteModal(false);
};
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, closeDeleteModal);
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}`;
}
};
// 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={clsx("md:flex", { "opacity-25": showDeleteModal })}>
<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">
{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 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>
</>
) : (
<>
{/* 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={onContinueAsGuestClick}
>
<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={card.demoImageSrc}
style={{
left: `${x}%`,
position: "relative",
zoom: "0.5",
marginBottom: "1%",
}}
>
<CardImage
imageSrc={card.demoImageSrc}
compact={false}
stage={0}
/>
</div>
);
})}
</div>
</div>
</div>
{showDeleteModal && (
<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={() => {
setShowDeleteModal(false);
}}
>
Cancel
</Button>
<Button
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={handleAccountDelete}
>
Delete Account
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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>
);
}