mirror of
https://github.com/20kaushik02/spotify-manager-web.git
synced 2026-01-25 16:14:06 +00:00
typescript! yay! (pain.)
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
17
src/pages/Login/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
16
src/pages/Logout/index.tsx
Normal file
16
src/pages/Logout/index.tsx
Normal 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;
|
||||
@@ -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
|
||||
13
src/pages/PageNotFound/index.tsx
Normal file
13
src/pages/PageNotFound/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user