mirror of
https://github.com/20kaushik02/spotify-manager-web.git
synced 2026-01-25 08:04:05 +00:00
a lil abstraction, styling
This commit is contained in:
@@ -27,7 +27,7 @@ import { ReactFlowProvider } from "@xyflow/react";
|
||||
// Contexts
|
||||
export const WidthContext = createContext(0);
|
||||
export const AuthContext = createContext(false);
|
||||
export const RefreshAuthContext = createContext<any>(null);
|
||||
export const RefreshAuthContext = createContext(async () => false);
|
||||
|
||||
function App() {
|
||||
// States
|
||||
@@ -123,9 +123,8 @@ function App() {
|
||||
<ToastContainer
|
||||
position={"bottom-center"}
|
||||
theme={"dark"}
|
||||
stacked
|
||||
newestOnTop
|
||||
draggable
|
||||
closeOnClick
|
||||
/>
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { apiRespBase, axiosInstance } from "./axiosInstance";
|
||||
import { apiRespBaseType, axiosInstance } from "./axiosInstance";
|
||||
import { authHealthCheckURL, authRefreshURL } from "./paths";
|
||||
|
||||
export const apiAuthCheck = async (): Promise<
|
||||
AxiosResponse<apiRespBase, any>
|
||||
AxiosResponse<apiRespBaseType, any>
|
||||
> => {
|
||||
try {
|
||||
const response = await axiosInstance.get(authHealthCheckURL);
|
||||
@@ -14,7 +14,7 @@ export const apiAuthCheck = async (): Promise<
|
||||
};
|
||||
|
||||
export const apiAuthRefresh = async (): Promise<
|
||||
AxiosResponse<apiRespBase, any>
|
||||
AxiosResponse<apiRespBaseType, any>
|
||||
> => {
|
||||
try {
|
||||
const response = await axiosInstance.get(authRefreshURL);
|
||||
|
||||
@@ -6,11 +6,11 @@ export const axiosInstance = axios.create({
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export interface apiRespBase {
|
||||
message?: string,
|
||||
errors?: any[],
|
||||
};
|
||||
export interface apiRespBaseType {
|
||||
message?: string;
|
||||
errors?: any[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { apiRespBase, axiosInstance } from "./axiosInstance";
|
||||
import { opFetchGraphURL } from "./paths";
|
||||
import { apiRespBaseType, axiosInstance } from "./axiosInstance";
|
||||
import { opFetchGraphURL, opUpdateUserDataURL } from "./paths";
|
||||
|
||||
interface fetchGraphDataType extends apiRespBase {
|
||||
interface fetchGraphDataType extends apiRespBaseType {
|
||||
playlists?: {
|
||||
playlistID: string;
|
||||
playlistName: string;
|
||||
@@ -13,6 +13,10 @@ interface fetchGraphDataType extends apiRespBase {
|
||||
}[];
|
||||
}
|
||||
|
||||
interface updateUserDataType extends apiRespBaseType {
|
||||
removedLinks: boolean;
|
||||
}
|
||||
|
||||
export const apiFetchGraph = async (): Promise<
|
||||
AxiosResponse<fetchGraphDataType, any>
|
||||
> => {
|
||||
@@ -23,3 +27,14 @@ export const apiFetchGraph = async (): Promise<
|
||||
return error.response;
|
||||
}
|
||||
};
|
||||
|
||||
export const apiUpdateUserData = async (): Promise<
|
||||
AxiosResponse<updateUserDataType, any>
|
||||
> => {
|
||||
try {
|
||||
const response = await axiosInstance.put(opUpdateUserDataURL);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return error.response;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,3 +7,4 @@ export const authHealthCheckURL = "auth-health";
|
||||
export const authRefreshURL = "api/auth/refresh";
|
||||
|
||||
export const opFetchGraphURL = "api/operations/fetch";
|
||||
export const opUpdateUserDataURL = "api/operations/update";
|
||||
|
||||
55
src/components/APIWrapper/index.tsx
Normal file
55
src/components/APIWrapper/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
import { apiRespBaseType } from "../../api/axiosInstance";
|
||||
import {
|
||||
showErrorToastNotification,
|
||||
showWarnToastNotification,
|
||||
} from "../ToastNotification";
|
||||
|
||||
const maxRetries = 3;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// TODO: refreshAuth fn needs to be prop drilled (well, it's not really 'drilling', but still it's a single level)
|
||||
// because hooks (namely, useContext) can't be used outside functional components
|
||||
// so find a better way to pass refreshAuth
|
||||
|
||||
type APIWrapperProps<T extends apiRespBaseType> = {
|
||||
apiFn(
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T, any>>;
|
||||
refreshAuth: () => Promise<boolean>;
|
||||
data?: any;
|
||||
config?: AxiosRequestConfig;
|
||||
};
|
||||
|
||||
const APIWrapper = async <T extends apiRespBaseType>({
|
||||
apiFn,
|
||||
refreshAuth,
|
||||
data,
|
||||
config,
|
||||
}: APIWrapperProps<T>) => {
|
||||
for (let i = 1; i <= maxRetries + 1; i++) {
|
||||
const apiResp = await apiFn(data, config);
|
||||
|
||||
if (apiResp === undefined) {
|
||||
showErrorToastNotification("Please try again after sometime");
|
||||
} else if (apiResp.status === 200) {
|
||||
return apiResp;
|
||||
} else if (apiResp.status === 401) {
|
||||
showWarnToastNotification("Session expired, refreshing...");
|
||||
if (!(await refreshAuth())) {
|
||||
showErrorToastNotification("Session invalid.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
showErrorToastNotification(apiResp.data.message);
|
||||
}
|
||||
await sleep(i * i * 1000);
|
||||
}
|
||||
showErrorToastNotification("Please try again after sometime");
|
||||
return;
|
||||
};
|
||||
|
||||
export default APIWrapper;
|
||||
@@ -8,6 +8,6 @@
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
box-shadow: 8px 8px var(--bg);
|
||||
box-shadow: 4px 4px var(--bg);
|
||||
background-color: var(--bgLinkInactive);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,21 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--mb-3);
|
||||
gap: var(--mb-1);
|
||||
height: 100vh;
|
||||
width: 10vw;
|
||||
padding: var(--mb-3);
|
||||
}
|
||||
|
||||
.operations_wrapper .icons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
margin: var(--mb-2) auto;
|
||||
border-top: 1px solid white;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type OnDelete,
|
||||
type OnBeforeDelete,
|
||||
} from "@xyflow/react";
|
||||
import Dagre, { type GraphLabel } from "@dagrejs/dagre";
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import styles from "./Graph.module.css";
|
||||
@@ -30,16 +30,19 @@ import styles from "./Graph.module.css";
|
||||
import { IoIosGitNetwork } from "react-icons/io";
|
||||
import { WiCloudRefresh } from "react-icons/wi";
|
||||
import { MdOutlineLock, MdOutlineLockOpen } from "react-icons/md";
|
||||
import { AiFillSpotify } from "react-icons/ai";
|
||||
|
||||
import {
|
||||
showErrorToastNotification,
|
||||
showInfoToastNotification,
|
||||
showWarnToastNotification,
|
||||
} from "../../components/ToastNotification";
|
||||
|
||||
import { apiFetchGraph } from "../../api/operations";
|
||||
import { apiFetchGraph, apiUpdateUserData } from "../../api/operations";
|
||||
|
||||
import { RefreshAuthContext } from "../../App";
|
||||
import Button from "../../components/Button";
|
||||
import APIWrapper from "../../components/APIWrapper";
|
||||
|
||||
const initialNodes: Node[] = [];
|
||||
const initialEdges: Edge[] = [];
|
||||
@@ -67,11 +70,13 @@ const nodeOffsets = {
|
||||
},
|
||||
};
|
||||
|
||||
interface Interactive {
|
||||
type Interactive = {
|
||||
ndDrag: boolean;
|
||||
ndConn: boolean;
|
||||
elsSel: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
type rankdirType = "TB" | "BT" | "LR" | "RL";
|
||||
|
||||
const initialInteractive: Interactive = {
|
||||
ndDrag: true,
|
||||
@@ -147,12 +152,12 @@ const Graph = () => {
|
||||
);
|
||||
|
||||
type getLayoutedElementsOpts = {
|
||||
direction: GraphLabel["rankdir"];
|
||||
direction: rankdirType;
|
||||
};
|
||||
const getLayoutedElements = (
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: getLayoutedElementsOpts = { direction: "TB" }
|
||||
options: getLayoutedElementsOpts
|
||||
) => {
|
||||
const g = new Dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
@@ -224,7 +229,7 @@ const Graph = () => {
|
||||
return finalLayout;
|
||||
};
|
||||
|
||||
const arrangeLayout = (direction: GraphLabel["rankdir"]) => {
|
||||
const arrangeLayout = (direction: rankdirType) => {
|
||||
const layouted = getLayoutedElements(playlistNodes, linkEdges, {
|
||||
direction,
|
||||
});
|
||||
@@ -237,66 +242,59 @@ const Graph = () => {
|
||||
};
|
||||
|
||||
const fetchGraph = useCallback(async () => {
|
||||
const resp = await apiFetchGraph();
|
||||
if (resp === undefined) {
|
||||
showErrorToastNotification("Please try again after sometime");
|
||||
return;
|
||||
}
|
||||
if (resp.status === 200) {
|
||||
console.debug(
|
||||
`graph fetched with ${resp.data.playlists?.length} nodes and ${resp.data.links?.length} edges`
|
||||
);
|
||||
// place playlist nodes
|
||||
setPlaylistNodes(
|
||||
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,
|
||||
const resp = await APIWrapper({ apiFn: apiFetchGraph, refreshAuth });
|
||||
console.debug(
|
||||
`graph fetched with ${resp?.data.playlists?.length} nodes and ${resp?.data.links?.length} edges`
|
||||
);
|
||||
// place playlist nodes
|
||||
setPlaylistNodes(
|
||||
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,
|
||||
},
|
||||
data: {
|
||||
label: pl.playlistName,
|
||||
metadata: {
|
||||
pl,
|
||||
},
|
||||
},
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
// connect links
|
||||
setLinkEdges(
|
||||
resp.data.links?.map((link, idx) => {
|
||||
return {
|
||||
id: `${link.from}->${link.to}`,
|
||||
source: link.from,
|
||||
target: link.to,
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
showInfoToastNotification("Graph updated.");
|
||||
return;
|
||||
}
|
||||
if (resp.status >= 500) {
|
||||
showErrorToastNotification(resp.data.message);
|
||||
return;
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
await refreshAuth();
|
||||
}
|
||||
showErrorToastNotification(resp.data.message);
|
||||
return;
|
||||
},
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
// connect links
|
||||
setLinkEdges(
|
||||
resp?.data.links?.map((link, idx) => {
|
||||
return {
|
||||
id: `${link.from}->${link.to}`,
|
||||
source: link.from,
|
||||
target: link.to,
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
showInfoToastNotification("Graph updated.");
|
||||
}, [refreshAuth]);
|
||||
|
||||
const onArrange = () => {
|
||||
arrangeLayout("TB");
|
||||
const updateUserData = async () => {
|
||||
const resp = await APIWrapper({
|
||||
apiFn: apiUpdateUserData,
|
||||
refreshAuth,
|
||||
});
|
||||
showInfoToastNotification("Spotify synced.");
|
||||
if (resp?.data.removedLinks)
|
||||
showWarnToastNotification(
|
||||
"Some links with deleted playlists were removed."
|
||||
);
|
||||
await refreshGraph();
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
const refreshGraph = async () => {
|
||||
await fetchGraph();
|
||||
arrangeLayout("TB");
|
||||
};
|
||||
@@ -304,19 +302,30 @@ const Graph = () => {
|
||||
useEffect(() => {
|
||||
fetchGraph();
|
||||
// TODO: how to invoke async and sync fns in order correctly inside useEffect?
|
||||
// onRefresh();
|
||||
// refreshGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
const toggleInteractive = () => {
|
||||
const disableInteractive = () => {
|
||||
setInteractive({
|
||||
ndDrag: !interactive.ndDrag,
|
||||
ndConn: !interactive.ndConn,
|
||||
elsSel: !interactive.elsSel,
|
||||
ndDrag: false,
|
||||
ndConn: false,
|
||||
elsSel: false,
|
||||
});
|
||||
};
|
||||
const enableInteractive = () => {
|
||||
setInteractive({
|
||||
ndDrag: true,
|
||||
ndConn: true,
|
||||
elsSel: true,
|
||||
});
|
||||
};
|
||||
|
||||
const isInteractive = () => {
|
||||
return interactive.ndDrag && interactive.ndConn && interactive.elsSel;
|
||||
return interactive.ndDrag || interactive.ndConn || interactive.elsSel;
|
||||
};
|
||||
|
||||
const toggleInteractive = () => {
|
||||
isInteractive() ? disableInteractive() : enableInteractive();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -349,11 +358,11 @@ const Graph = () => {
|
||||
<Background variant={BackgroundVariant.Dots} gap={36} size={3} />
|
||||
</ReactFlow>
|
||||
<div className={styles.operations_wrapper}>
|
||||
<Button onClickMethod={onRefresh}>
|
||||
<Button onClickMethod={refreshGraph}>
|
||||
<WiCloudRefresh size={36} />
|
||||
Refresh
|
||||
Refresh Graph
|
||||
</Button>
|
||||
<Button onClickMethod={onArrange}>
|
||||
<Button onClickMethod={() => arrangeLayout("TB")}>
|
||||
<IoIosGitNetwork size={36} />
|
||||
Arrange
|
||||
</Button>
|
||||
@@ -365,6 +374,14 @@ const Graph = () => {
|
||||
)}
|
||||
{isInteractive() ? "Lock" : "Unlock"}
|
||||
</Button>
|
||||
<hr className={styles.divider} />
|
||||
<Button onClickMethod={updateUserData}>
|
||||
<span className={styles.icons}>
|
||||
<WiCloudRefresh size={36} />
|
||||
<AiFillSpotify size={36} />
|
||||
</span>
|
||||
Sync Spotify
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user