typescript! yay! (pain.)

This commit is contained in:
2024-12-31 03:47:10 -07:00
parent a837266dca
commit 6733a3be8e
39 changed files with 1188 additions and 335 deletions

View File

@@ -1,29 +1,33 @@
// Libraries
import { createContext, useEffect, useState } from 'react';
import { createContext, useEffect, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import { ToastContainer } from "react-toastify";
// Styles
import styles from './App.module.css';
import styles from "./App.module.css";
// Assets
// Utils
import ScrollToTop from './utils/ScrollToTop';
import ScrollToTop from "./utils/ScrollToTop";
// Components
import Navbar from './components/Navbar';
import Navbar from "./components/Navbar/index";
// Routes
import AllRoutes from './routes/AllRoutes';
import { showErrorToastNotification, showInfoToastNotification, showWarnToastNotification } from './components/ToastNotification';
import { apiAuthCheck, apiAuthRefresh } from './api/auth';
import { ReactFlowProvider } from '@xyflow/react';
import AllRoutes from "./routes/AllRoutes";
import {
showErrorToastNotification,
showInfoToastNotification,
showWarnToastNotification,
} from "./components/ToastNotification";
import { apiAuthCheck, apiAuthRefresh } from "./api/auth";
import { ReactFlowProvider } from "@xyflow/react";
// Contexts
export const WidthContext = createContext();
export const AuthContext = createContext();
export const RefreshAuthContext = createContext();
export const WidthContext = createContext(0);
export const AuthContext = createContext(false);
export const RefreshAuthContext = createContext<any>(null);
function App() {
// States
@@ -78,7 +82,7 @@ function App() {
setAuth(false);
showWarnToastNotification(resp.data.message);
return false;
}
};
useEffect(() => {
(async () => {
@@ -117,7 +121,6 @@ function App() {
</div>
</BrowserRouter>
<ToastContainer
id={"notif-container"}
position={"bottom-center"}
theme={"dark"}
stacked

View File

@@ -1,20 +0,0 @@
import { axiosInstance } from "./axiosInstance";
import { authHealthCheckURL, authRefreshURL } from "./paths";
export const apiAuthCheck = async () => {
try {
const response = await axiosInstance.get(authHealthCheckURL);
return response;
} catch (error) {
return error.response;
}
}
export const apiAuthRefresh = async () => {
try {
const response = await axiosInstance.get(authRefreshURL);
return response;
} catch (error) {
return error.response;
}
}

25
src/api/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
import { AxiosResponse } from "axios";
import { apiRespBase, axiosInstance } from "./axiosInstance";
import { authHealthCheckURL, authRefreshURL } from "./paths";
export const apiAuthCheck = async (): Promise<
AxiosResponse<apiRespBase, any>
> => {
try {
const response = await axiosInstance.get(authHealthCheckURL);
return response;
} catch (error: any) {
return error.response;
}
};
export const apiAuthRefresh = async (): Promise<
AxiosResponse<apiRespBase, any>
> => {
try {
const response = await axiosInstance.get(authRefreshURL);
return response;
} catch (error: any) {
return error.response;
}
};

View File

@@ -9,3 +9,8 @@ export const axiosInstance = axios.create({
"Content-Type": "application/json"
},
});
export interface apiRespBase {
message?: string,
errors?: any[],
};

View File

@@ -1,11 +0,0 @@
import { axiosInstance } from "./axiosInstance";
import { opFetchGraphURL } from "./paths";
export const apiFetchGraph = async () => {
try {
const response = await axiosInstance.get(opFetchGraphURL);
return response;
} catch (error) {
return error.response;
}
}

25
src/api/operations.ts Normal file
View File

@@ -0,0 +1,25 @@
import { AxiosResponse } from "axios";
import { apiRespBase, axiosInstance } from "./axiosInstance";
import { opFetchGraphURL } from "./paths";
interface fetchGraphDataType extends apiRespBase {
playlists?: {
playlistID: string;
playlistName: string;
}[];
links?: {
from: string; // playlistID
to: string; // playlistID
}[];
}
export const apiFetchGraph = async (): Promise<
AxiosResponse<fetchGraphDataType, any>
> => {
try {
const response = await axiosInstance.get(opFetchGraphURL);
return response;
} catch (error: any) {
return error.response;
}
};

View File

@@ -1,6 +1,6 @@
/* the value 54150 is decided by getting the length of the path (54122 for the logo asset) */
.svgWrapper path {
.svg_wrapper path {
stroke-dasharray: 54150;
stroke-dashoffset: 54150;
animation: draw 5s ease-in-out infinite;

View File

@@ -1,13 +1,15 @@
import React from 'react';
import React from "react";
import styles from "./AnimatedSVG.module.css";
const AnimatedSVG = ({ stroke = "#ffffff" }) => {
const AnimatedSVG = () => {
const stroke = "#fff";
return (
<div className={styles.svgWrapper}>
<div className={styles.svg_wrapper}>
{/* width, height and viewBox are necessary */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="256" height="256" // adjust size here
width="256"
height="256" // adjust size here
viewBox="0 0 512 512"
preserveAspectRatio="xMidYMid meet"
>
@@ -35,12 +37,12 @@ const AnimatedSVG = ({ stroke = "#ffffff" }) => {
strokeMiterlimit={10}
fill="none"
id="svglength"
// document.getElementById('svglength').getTotalLength()
// document.getElementById('svglength').getTotalLength()
/>
</g>
</svg>
</div>
)
}
);
};
export default AnimatedSVG;

View File

@@ -1,18 +0,0 @@
import React from 'react';
import styles from "./Button.module.css";
function Button({ children, onClickMethod }) {
const clickHandler = (e) => {
e.preventDefault();
onClickMethod();
}
return (
<button type="button"
className={styles.btn_wrapper}
onClick={clickHandler}>
{children}
</button>
)
}
export default Button;

View File

@@ -0,0 +1,21 @@
import React from "react";
import styles from "./Button.module.css";
type ButtonProps = {
children: React.ReactNode;
onClickMethod: () => void;
}
const Button = ({ children, onClickMethod }: ButtonProps) => {
const clickHandler = (e: React.MouseEvent) => {
e.preventDefault();
onClickMethod();
};
return (
<button type="button" className={styles.btn_wrapper} onClick={clickHandler}>
{children}
</button>
);
};
export default Button;

View File

@@ -1,24 +0,0 @@
import React, { useContext } from 'react'
import styles from "./Navbar.module.css";
import { AuthContext } from "../../App";
import StyledNavLink from '../StyledNavLink';
const Navbar = () => {
const auth = useContext(AuthContext);
return (
<nav className={styles.navbar_wrapper}>
<StyledNavLink exact path="/" text="About" />
<StyledNavLink exact path="/graph" text="Graph" />
{
auth === true ?
<StyledNavLink exact path="/logout" text="Logout" /> :
<StyledNavLink exact path="/login" text="Login" />
}
</nav>
)
}
export default Navbar

View File

@@ -0,0 +1,24 @@
import React, { useContext } from "react";
import styles from "./Navbar.module.css";
import { AuthContext } from "../../App";
import StyledNavLink from "../StyledNavLink/index";
const Navbar = () => {
const auth = useContext(AuthContext);
return (
<nav className={styles.navbar_wrapper}>
<StyledNavLink path="/" text="About" />
<StyledNavLink path="/graph" text="Graph" />
{auth === true ? (
<StyledNavLink path="/logout" text="Logout" />
) : (
<StyledNavLink path="/login" text="Login" />
)}
</nav>
);
};
export default Navbar;

View File

@@ -1,32 +0,0 @@
import React from 'react'
import { NavLink } from 'react-router-dom';
import styles from "./StyledNavLink.module.css";
/**
* @param {{
* path: string,
* text: string,
* activeClass: string,
* inactiveClass: string
* }}
* @returns
*/
const StyledNavLink = ({
path = "/",
text = "Go To",
activeClass = styles.active_link,
inactiveClass = styles.inactive_link
}) => {
return (
<NavLink
to={path}
className={({ isActive }) => isActive ? activeClass : inactiveClass}
>
{text}
</NavLink>
)
}
export default StyledNavLink;

View File

@@ -0,0 +1,28 @@
import React from "react";
import { NavLink } from "react-router-dom";
import styles from "./StyledNavLink.module.css";
type StyledNavLinkProps = {
path: string;
text: string;
activeClass?: string;
inactiveClass?: string;
}
const StyledNavLink = ({
path = "/",
text = "Go To",
activeClass = styles.active_link,
inactiveClass = styles.inactive_link,
}: StyledNavLinkProps): React.ReactNode => {
return (
<NavLink
to={path}
className={({ isActive }) => (isActive ? activeClass : inactiveClass)}
>
{text}
</NavLink>
);
};
export default StyledNavLink;

View File

@@ -1,17 +0,0 @@
import { toast } from "react-toastify";
export const showErrorToastNotification = (message) => {
toast.error(message || "Server Error");
};
export const showSuccessToastNotification = (message) => {
toast.success(message || "Success");
};
export const showWarnToastNotification = (message) => {
toast.warn(message || "Warning");
};
export const showInfoToastNotification = (message) => {
toast.info(message || "Info");
};

View File

@@ -0,0 +1,17 @@
import { toast, type ToastContent } from "react-toastify";
export const showErrorToastNotification = (message: ToastContent) => {
toast.error(message || "Server Error");
};
export const showSuccessToastNotification = (message: ToastContent) => {
toast.success(message || "Success");
};
export const showWarnToastNotification = (message: ToastContent) => {
toast.warn(message || "Warning");
};
export const showInfoToastNotification = (message: ToastContent) => {
toast.info(message || "Info");
};

1
src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.module.css";

View File

@@ -1,10 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(document.getElementById('root'));
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<App />

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useEffect } from 'react';
import React, { useCallback, useContext, useEffect } from "react";
import {
ReactFlow,
Controls,
@@ -8,55 +8,57 @@ import {
addEdge,
useReactFlow,
MarkerType,
} from '@xyflow/react';
import Dagre from '@dagrejs/dagre';
BackgroundVariant,
ConnectionLineType,
type DefaultEdgeOptions,
type ProOptions,
type ReactFlowInstance,
type Node,
type Edge,
type OnConnect,
} from "@xyflow/react";
import Dagre, { type GraphLabel } from "@dagrejs/dagre";
import '@xyflow/react/dist/style.css';
import styles from './Graph.module.css';
import "@xyflow/react/dist/style.css";
import styles from "./Graph.module.css";
import { showErrorToastNotification, showInfoToastNotification } from '../../components/ToastNotification';
import {
showErrorToastNotification,
showInfoToastNotification,
} from "../../components/ToastNotification";
import { apiFetchGraph } from '../../api/operations';
import { apiFetchGraph } from "../../api/operations";
import { RefreshAuthContext } from "../../App";
import Button from '../../components/Button';
import Button from "../../components/Button";
const initialNodes = [];
// const initialNodes = [
// { id: '1', position: { x: 0, y: 0 }, data: { label: '1' } },
// { id: '2', position: { x: 0, y: 100 }, data: { label: '2' } },
// { id: '3', position: { x: 50, y: 50 }, data: { label: '3' } },
// ];
const initialEdges = [];
// const initialEdges = [
// { id: 'e1-2', source: '1', target: '2' }
// ];
const initialNodes: any[] = [];
const initialEdges: any[] = [];
const nodeOffsets = {
connected: {
origin: {
x: 0,
y: 0
y: 0,
},
scaling: {
x: 240,
y: 80
}
y: 80,
},
},
unconnected: {
origin: {
x: 800,
y: 0
y: 0,
},
scaling: {
x: 160,
y: 40
}
}
}
y: 40,
},
},
};
/** @type {import('@xyflow/react').DefaultEdgeOptions} */
const edgeOptions = {
const edgeOptions: DefaultEdgeOptions = {
animated: true,
style: {
stroke: "white",
@@ -67,10 +69,10 @@ const edgeOptions = {
color: "white",
width: 40,
height: 40,
}
},
};
const proOptions = { hideAttribution: true };
const proOptions: ProOptions = { hideAttribution: true };
const Graph = () => {
const refreshAuth = useContext(RefreshAuthContext);
@@ -78,39 +80,65 @@ const Graph = () => {
const [playlistNodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [linkEdges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onFlowInit = (instance) => {
const onFlowInit = (instance: ReactFlowInstance) => {
console.debug("flow loaded");
}
};
const onConnect = useCallback((params) => {
setEdges((eds) => addEdge(params, eds));
console.debug("new connection");
console.debug(params);
}, [setEdges]);
const onConnect: OnConnect = useCallback(
(params) => {
setEdges((eds) => addEdge(params, eds));
console.debug("new connection");
console.debug(params);
},
[setEdges]
);
const getLayoutedElements = (nodes, edges, options = { direction: "TB" }) => {
const g = new Dagre.graphlib.Graph()
type getLayoutedElementsOpts = {
direction: GraphLabel["rankdir"];
};
const getLayoutedElements = (
nodes: Node[],
edges: Edge[],
options: getLayoutedElementsOpts = { direction: "TB" }
) => {
const g = new Dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: options.direction, nodesep: 200, edgesep: 200, ranksep: 200 });
g.setGraph({
rankdir: options.direction,
nodesep: 200,
edgesep: 200,
ranksep: 200,
});
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
const connectedNodesID = new Set(edges.flatMap(edge => [edge.source, edge.target]));
const connectedNodes = nodes.filter(node => connectedNodesID.has(node.id));
const unconnectedNodes = nodes.filter(node => !connectedNodesID.has(node.id));
const connectedNodesID = new Set(
edges.flatMap((edge) => [edge.source, edge.target])
);
const connectedNodes = nodes.filter((node) =>
connectedNodesID.has(node.id)
);
const unconnectedNodes = nodes.filter(
(node) => !connectedNodesID.has(node.id)
);
nodes.forEach((node) => {
g.setNode(node.id, {
...node,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
})
});
});
Dagre.layout(g);
let finalLayout = { edges };
finalLayout.nodes = [
let finalLayout: { nodes: Node[]; edges: Edge[] } = {
nodes: [],
edges: [],
};
finalLayout.edges = edges;
finalLayout.nodes.push(
...connectedNodes.map((node) => {
const position = g.node(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
@@ -119,32 +147,41 @@ const Graph = () => {
const y = position.y - (node.measured?.height ?? 0) / 2;
return { ...node, position: { x, y } };
}),
})
);
finalLayout.nodes.push(
...unconnectedNodes.map((node, idx) => {
const position = {
x: nodeOffsets.unconnected.origin.x + Math.floor(idx / 20) * nodeOffsets.unconnected.scaling.x,
y: nodeOffsets.unconnected.origin.y + Math.floor(idx % 20) * nodeOffsets.unconnected.scaling.y,
x:
nodeOffsets.unconnected.origin.x +
Math.floor(idx / 20) * nodeOffsets.unconnected.scaling.x,
y:
nodeOffsets.unconnected.origin.y +
Math.floor(idx % 20) * nodeOffsets.unconnected.scaling.y,
};
const x = position.x - (node.measured?.width ?? 0) / 2;
const y = position.y - (node.measured?.height ?? 0) / 2;
return { ...node, position: { x, y } };
})
];
);
console.debug("layout generated");
return finalLayout;
};
const arrangeLayout = (direction) => {
const layouted = getLayoutedElements(playlistNodes, linkEdges, { direction });
const arrangeLayout = (direction: GraphLabel["rankdir"]) => {
const layouted = getLayoutedElements(playlistNodes, linkEdges, {
direction,
});
setNodes([...layouted.nodes]);
setEdges([...layouted.edges]);
setTimeout(flowInstance.fitView);
console.debug("layout applied");
}
};
useEffect(() => {
const fetchGraph = async () => {
@@ -155,29 +192,37 @@ const Graph = () => {
}
if (resp.status === 200) {
// place playlist nodes
setNodes(resp.data.playlists.map((pl, idx) => {
return {
id: `${pl.playlistID}`,
position: {
x: nodeOffsets.unconnected.origin.x + Math.floor(idx / 15) * nodeOffsets.unconnected.scaling.x,
y: nodeOffsets.unconnected.origin.y + Math.floor(idx % 15) * nodeOffsets.unconnected.scaling.y,
},
data: {
label: pl.playlistName,
metadata: {
pl
}
}
}
}));
setNodes(
resp.data.playlists?.map((pl, idx) => {
return {
id: `${pl.playlistID}`,
position: {
x:
nodeOffsets.unconnected.origin.x +
Math.floor(idx / 15) * nodeOffsets.unconnected.scaling.x,
y:
nodeOffsets.unconnected.origin.y +
Math.floor(idx % 15) * nodeOffsets.unconnected.scaling.y,
},
data: {
label: pl.playlistName,
metadata: {
pl,
},
},
};
}) ?? []
);
// connect links
setEdges(resp.data.links.map((link, idx) => {
return {
id: `${idx}`,
source: link.from,
target: link.to
}
}));
setEdges(
resp.data.links?.map((link, idx) => {
return {
id: `${idx}`,
source: link.from,
target: link.to,
};
}) ?? []
);
showInfoToastNotification("Graph updated.");
return;
}
@@ -186,11 +231,11 @@ const Graph = () => {
return;
}
if (resp.status === 401) {
await refreshAuth();
refreshAuth();
}
showErrorToastNotification(resp.data.message);
return;
}
};
fetchGraph();
}, [refreshAuth, setEdges, setNodes]);
@@ -200,7 +245,7 @@ const Graph = () => {
nodes={playlistNodes}
edges={linkEdges}
defaultEdgeOptions={edgeOptions}
connectionLineType="smoothstep"
connectionLineType={ConnectionLineType.SmoothStep}
fitView
proOptions={proOptions}
colorMode={"light"}
@@ -210,17 +255,17 @@ const Graph = () => {
onConnect={onConnect}
>
<Controls />
<Background variant='dots' gap={36} size={3} />
<Background variant={BackgroundVariant.Dots} gap={36} size={3} />
{/* <Panel position="top-right"> */}
{/* <button onClick={() => arrangeLayout('TB')}>Arrange vertically</button> */}
{/* <button onClick={() => arrangeLayout('LR')}>Arrange horizontally</button> */}
{/* </Panel> */}
</ReactFlow>
<div className={styles.operations_wrapper}>
<Button onClickMethod={() => arrangeLayout('TB')}>Arrange</Button>
<Button onClickMethod={() => arrangeLayout("TB")}>Arrange</Button>
</div>
</div>
)
}
);
};
export default Graph;

View File

@@ -1,13 +1,14 @@
import React, { useEffect } from "react"
import React, { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import styles from "./Landing.module.css"
import { showInfoToastNotification, showSuccessToastNotification } from "../../components/ToastNotification";
import styles from "./Landing.module.css";
import {
showInfoToastNotification,
showSuccessToastNotification,
} from "../../components/ToastNotification";
import AnimatedSVG from "../../components/AnimatedSVG";
const Landing = () => {
// eslint-disable-next-line no-unused-vars
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
useEffect(() => {
if (searchParams.get("login") === "success") {
showSuccessToastNotification("Logged in!");
@@ -27,7 +28,7 @@ const Landing = () => {
<li>Periodic syncing</li>
</ul>
</>
)
}
);
};
export default Landing
export default Landing;

View File

@@ -1,21 +0,0 @@
import React, { useEffect } from 'react';
import styles from './Login.module.css';
import { authLoginURL } from '../../api/paths';
// auth through backend
const Login = () => {
useEffect(() => {
const timeoutID = setTimeout(() => {
window.open(authLoginURL, "_self")
}, 1000);
return () => clearTimeout(timeoutID);
}, []);
return (
<div className={styles.login_wrapper}>
Redirecting to Spotify...
</div>
)
}
export default Login;

17
src/pages/Login/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React, { useEffect } from "react";
import styles from "./Login.module.css";
import { authLoginURL } from "../../api/paths";
// auth through backend
const Login = () => {
useEffect(() => {
const timeoutID = setTimeout(() => {
window.open(authLoginURL, "_self");
}, 1000);
return () => clearTimeout(timeoutID);
}, []);
return <div className={styles.login_wrapper}>Redirecting to Spotify...</div>;
};
export default Login;

View File

@@ -1,20 +0,0 @@
import React, { useEffect } from 'react';
import styles from './Logout.module.css';
import { authLogoutURL } from '../../api/paths';
const Logout = () => {
useEffect(() => {
const timeoutID = setTimeout(() => {
window.open(authLogoutURL, "_self")
}, 1000);
return () => clearTimeout(timeoutID);
}, []);
return (
<div className={styles.logout_wrapper}>
See you soon!
</div>
)
}
export default Logout;

View File

@@ -0,0 +1,16 @@
import React, { useEffect } from "react";
import styles from "./Logout.module.css";
import { authLogoutURL } from "../../api/paths";
const Logout = () => {
useEffect(() => {
const timeoutID = setTimeout(() => {
window.open(authLogoutURL, "_self");
}, 1000);
return () => clearTimeout(timeoutID);
}, []);
return <div className={styles.logout_wrapper}>See you soon!</div>;
};
export default Logout;

View File

@@ -1,17 +0,0 @@
import React, { useEffect } from 'react';
import styles from "./PageNotFound.module.css";
import { showWarnToastNotification } from '../../components/ToastNotification';
const PageNotFound = () => {
useEffect(() => {
showWarnToastNotification("Oops!")
}, []);
return (
<div className={styles.pnf_wrapper}>
PageNotFound
</div>
)
}
export default PageNotFound

View File

@@ -0,0 +1,13 @@
import React, { useEffect } from "react";
import styles from "./PageNotFound.module.css";
import { showWarnToastNotification } from "../../components/ToastNotification";
const PageNotFound = () => {
useEffect(() => {
showWarnToastNotification("Oops!");
}, []);
return <div className={styles.pnf_wrapper}>Page Not Found</div>;
};
export default PageNotFound;

View File

@@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

12
src/reportWebVitals.ts Normal file
View File

@@ -0,0 +1,12 @@
import { type MetricType, onCLS, onFCP, onLCP, onTTFB } from "web-vitals";
const reportWebVitals = (onPerfEntry?: (metric: MetricType) => void): void => {
if (onPerfEntry && onPerfEntry instanceof Function) {
onCLS(onPerfEntry);
onFCP(onPerfEntry);
onLCP(onPerfEntry);
onTTFB(onPerfEntry);
}
};
export default reportWebVitals;

View File

@@ -14,21 +14,21 @@ const AllRoutes = () => {
<Routes>
{/* Routes that require user to be logged in */}
<Route element={<AuthOnlyRoutes />}>
<Route exact path="/logout" element={<Logout />} />
<Route exact path="/graph" element={<Graph />} />
{/* <Route exact path="/playlists" element={<Playlists />} /> */}
<Route path="/logout" element={<Logout />} />
<Route path="/graph" element={<Graph />} />
{/* <Route path="/playlists" element={<Playlists />} /> */}
</Route>
{/* Routes that require user to be logged *out* */}
<Route element={<UnAuthOnlyRoutes />}>
<Route exact path="/login" element={<Login />} />
<Route path="/login" element={<Login />} />
</Route>
{/* Common routes */}
<Route exact path="/" element={<Landing />} />
<Route path="/" element={<Landing />} />
{/* 404 */}
<Route exact path="/page-not-found" element={<PageNotFound />} />
<Route path="/page-not-found" element={<PageNotFound />} />
<Route path="*" element={<PageNotFound />} />
</Routes>
);

View File

@@ -1,16 +0,0 @@
/**
* Returns a string with zero padding of the number
*
* @param {number} num Input number (positive integer only, for now)
* @param {number} places Number of zeroes to pad
* @param {"before" | "after"} position Position of zeroes
* @returns {string} Zero-padded string
*/
export const zeroPaddedString = (num, places, position) => {
if (num < 0) throw new Error("negative number");
if (places < 0) throw new Error("invalid number of zeroes");
if (position !== "before" && position !== "after") throw new Error("invalid position (before or after only)");
const zeroes = "0".repeat(places);
return position === "before" ? '' + zeroes + num : '' + num + zeroes;
}

13
src/utils/numFormatter.ts Normal file
View File

@@ -0,0 +1,13 @@
export const zeroPaddedString = (
num: number,
places: number,
position: "before" | "after"
): string => {
if (num < 0) throw new Error("negative number");
if (places < 0) throw new Error("invalid number of zeroes");
if (position !== "before" && position !== "after")
throw new Error("invalid position (before or after only)");
const zeroes = "0".repeat(places);
return position === "before" ? "" + zeroes + num : "" + num + zeroes;
};