a lil abstraction, styling

This commit is contained in:
2025-01-05 02:23:23 -07:00
parent f471c666e7
commit 8898fa0b9f
10 changed files with 273 additions and 135 deletions

View File

@@ -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>

View File

@@ -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);

View File

@@ -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[];
}

View File

@@ -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;
}
};

View File

@@ -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";

View 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;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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>
);