import/export data, nav layout change, auth state improvement, css corrections and improvements

This commit is contained in:
Kaushik Narayan R 2025-03-17 21:26:28 -07:00
parent 098706a70e
commit e5e0751e8f
19 changed files with 184 additions and 43 deletions

View File

@ -19,13 +19,9 @@
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#443c9c" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<meta name="description" content="Frontend for spotify-manager" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
@ -40,7 +36,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Spotify Manager</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "Spotify Manager",
"name": "Spotify Manager",
"icons": [
{
"src": "android-chrome-192x192.png",

View File

@ -33,7 +33,7 @@ export const RefreshAuthContext: Context<() => Promise<boolean>> =
function App(): React.ReactNode {
// States
const [width, setWidth] = useState(0);
const [auth, setAuth] = useState(false);
const [auth, setAuth] = useState(true);
const refreshAuth = async () => {
// reauth
@ -82,7 +82,9 @@ function App(): React.ReactNode {
showErrorToastNotification(resp.data.message);
return false;
}
if (resp.status === 401) await refreshAuth();
if (resp.status === 401) {
return await refreshAuth();
}
setAuth(false);
showWarnToastNotification(resp.data.message);
return false;

20
src/api/load.ts Normal file
View File

@ -0,0 +1,20 @@
import type { AxiosResponse } from "axios";
import { type apiRespBaseType, axiosInstance } from "./axiosInstance.ts";
import { loadImportDataURL } from "./paths.ts";
interface importGraphDataType extends apiRespBaseType {}
export const apiImportGraph = async (
data: File
): Promise<AxiosResponse<importGraphDataType, any>> => {
try {
const formData = new FormData();
formData.append("dataFile", data);
const response = await axiosInstance.put(loadImportDataURL, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response;
} catch (error: any) {
return error.response;
}
};

View File

@ -18,3 +18,7 @@ export const opBackfillLinkURL = "api/operations/populate/link";
export const opBackfillChainURL = "api/operations/populate/chain";
export const opPruneLinkURL = "api/operations/prune/link";
export const opPruneChainURL = "api/operations/prune/chain";
export const loadExportDataURL = "api/load";
export const loadExportDataFullURL: string = backendDomain + loadExportDataURL;
export const loadImportDataURL = "api/load";

View File

@ -10,12 +10,11 @@ const Navbar = (): React.ReactNode => {
return (
<nav className={`${styles.navbar_wrapper} custom_scrollbar`}>
<StyledNavLink path="/" text="About" />
<StyledNavLink path="/" text="Home" />
<StyledNavLink path="/graph" text="Graph" />
<StyledNavLink path="/how-to" text="How To" />
<StyledNavLink path="/settings" text="Settings" />
{auth === true ? (
<StyledNavLink path="/logout" text="Logout" />
<StyledNavLink path="/settings" text="Settings" />
) : (
<StyledNavLink path="/login" text="Login" />
)}

View File

@ -3,7 +3,7 @@
/* Font Size */
:root {
--normalFontSize: 16px;
--headingFontSize: 24px;
--headingFontSize: 48px;
--headingFont: AileronFont;
--primaryFont: AileronFont;
--text: whitesmoke;
@ -66,6 +66,10 @@ ul {
list-style: outside;
}
h1 {
font-size: var(--headingFontSize);
}
img {
max-width: 100%;
height: auto;
@ -106,3 +110,11 @@ code {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: var(--bg);
}
.divider {
display: block;
height: 1px;
width: 100%;
margin: var(--mb-2) auto;
border-top: 1px solid white;
}

View File

@ -30,11 +30,3 @@
display: flex;
flex-direction: row;
}
.divider {
display: block;
height: 1px;
width: 100%;
margin: var(--mb-2) auto;
border-top: 1px solid white;
}

View File

@ -249,7 +249,10 @@ const Graph = (): React.ReactNode => {
showErrorToastNotification("Can't delete playlists!");
return false;
}
if (!edges[0]) throw new ReferenceError("no edge selected");
if (!edges[0]) {
showWarnToastNotification("Select a link!");
return false;
}
console.debug(
`deleted connection: ${edges[0].source} -> ${edges[0].target}`
);
@ -598,7 +601,7 @@ const Graph = (): React.ReactNode => {
</span>
Backfill Chain
</Button>
<hr className={styles.divider} />
<hr className="divider" />
<Button disabled={loading} onClickMethod={pruneLink}>
<IoArrowDownOutline size={36} />
Prune Link
@ -610,7 +613,7 @@ const Graph = (): React.ReactNode => {
</span>
Prune Chain
</Button>
<hr className={styles.divider} />
<hr className="divider" />
<Button disabled={loading} onClickMethod={() => arrangeLayout("TB")}>
<IoIosGitNetwork size={36} />
Arrange
@ -623,7 +626,7 @@ const Graph = (): React.ReactNode => {
)}
{isInteractive() ? "Lock" : "Unlock"}
</Button>
<hr className={styles.divider} />
<hr className="divider" />
<Button disabled={loading} onClickMethod={updateUserData}>
<span className={styles.icons}>
<WiCloudRefresh size={36} />

View File

@ -3,5 +3,4 @@
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}

View File

@ -4,7 +4,6 @@
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}
.app_logo {

View File

@ -3,5 +3,4 @@
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}

View File

@ -3,7 +3,7 @@ import styles from "./Login.module.css";
import { authLoginFullURL } from "../../api/paths.ts";
// auth through backend
const Login = ():React.ReactNode => {
const Login = (): React.ReactNode => {
useEffect(() => {
const timeoutID = setTimeout(() => {
window.open(authLoginFullURL, "_self");
@ -11,7 +11,11 @@ const Login = ():React.ReactNode => {
return () => clearTimeout(timeoutID);
}, []);
return <div className={styles.login_wrapper}>Logging in to Spotify...</div>;
return (
<div className={styles.login_wrapper}>
<h1>Logging in to Spotify...</h1>
</div>
);
};
export default Login;

View File

@ -3,5 +3,4 @@
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}

View File

@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import styles from "./Logout.module.css";
import { authLogoutFullURL } from "../../api/paths.ts";
const Logout = ():React.ReactNode => {
const Logout = (): React.ReactNode => {
useEffect(() => {
const timeoutID = setTimeout(() => {
window.open(authLogoutFullURL, "_self");
@ -10,7 +10,11 @@ const Logout = ():React.ReactNode => {
return () => clearTimeout(timeoutID);
}, []);
return <div className={styles.logout_wrapper}>See you soon!</div>;
return (
<div className={styles.logout_wrapper}>
<h1>See you soon!</h1>
</div>
);
};
export default Logout;

View File

@ -1,7 +1,7 @@
.pnf_wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}

View File

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

View File

@ -4,10 +4,45 @@
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}
.settings_controls {
padding: var(--mb-4);
width: 20vw;
gap: var(--mb-2);
padding: var(--mb-2);
background-color: var(--bgNav);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.settings_dataFile {
color: transparent;
}
.settings_dataFile::-webkit-file-upload-button {
visibility: hidden;
}
.settings_dataFile::before {
content: "Upload";
color: var(--text);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--bg);
/* border: 1px solid #999; */
padding: var(--mb-2) 0;
outline: none;
white-space: nowrap;
user-select: none;
-webkit-user-select: none;
cursor: pointer;
}
.settings_dataFile:hover::before {
border-color: black;
}
.settings_dataFile_selection {
text-align: center;
}

View File

@ -1,14 +1,82 @@
import React from "react";
import React, { useContext, useState } from "react";
import styles from "./Settings.module.css";
import Button from "../../components/Button/index.tsx";
import { loadExportDataFullURL } from "../../api/paths.ts";
import { useNavigate } from "react-router-dom";
import {
showSuccessToastNotification,
showWarnToastNotification,
} from "../../components/ToastNotification/index.tsx";
import APIWrapper from "../../components/APIWrapper/index.tsx";
import { apiImportGraph } from "../../api/load.ts";
import { RefreshAuthContext } from "../../App.tsx";
import SimpleLoader from "../../components/SimpleLoader/index.tsx";
const Settings = (): React.ReactNode => {
const refreshAuth = useContext(RefreshAuthContext);
const navigate = useNavigate();
const [dataFile, setDataFile] = useState<File>();
const [loading, setLoading] = useState<boolean>(false);
// let backend handle the attachment
const exportGraph = () => {
window.open(loadExportDataFullURL);
return;
};
const dataFileChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
let selectedFile = e.target.files[0];
if (!selectedFile) return;
if (selectedFile.type !== "application/json") {
showWarnToastNotification("Must be JSON file!");
return;
}
setDataFile(selectedFile);
};
const importGraph = async () => {
if (!dataFile) {
showWarnToastNotification("Select a file!");
return;
}
setLoading(true);
const resp = await APIWrapper({
apiFn: apiImportGraph,
data: dataFile,
refreshAuth,
});
setLoading(false);
if (resp?.status === 200) {
showSuccessToastNotification(resp.data.message);
}
return;
};
return (
<div className={styles.settings_wrapper}>
{loading && <SimpleLoader />}
<h1>Settings</h1>
<hr className="divider" />
<div className={styles.settings_controls}>
<Button>Export Data</Button>
<Button>Import Data</Button>
<Button disabled={loading} onClickMethod={exportGraph}>
Export Data
</Button>
<input
type="file"
name="dataFile"
onChange={dataFileChangeHandler}
className={styles.settings_dataFile}
/>
<p className={styles.settings_dataFile_selection}>
{dataFile ? `Selected file: ${dataFile.name}` : "No file selected"}
</p>
<Button disabled={loading} onClickMethod={importGraph}>
Import Data
</Button>
<Button disabled={loading} onClickMethod={() => navigate("/logout")}>
Log Out
</Button>
</div>
</div>
);