diff options
author | Parker <contact@pkrm.dev> | 2024-11-15 00:30:43 -0600 |
---|---|---|
committer | Parker <contact@pkrm.dev> | 2024-11-15 00:30:43 -0600 |
commit | 04cc3869c2844bb82ac6975ee218141104385c35 (patch) | |
tree | 1a4c9622606ea4e562a00f6fc022d7c802e3742d | |
parent | dbc53a555e64fdd0b848bf33b4208820b8701509 (diff) |
Extra route + Much more UI
-rw-r--r-- | api/routes/auth_routes.py | 2 | ||||
-rw-r--r-- | api/routes/log_routes.py | 41 | ||||
-rw-r--r-- | app/src/App.tsx | 2 | ||||
-rw-r--r-- | app/src/components/CreateLink.tsx | 100 | ||||
-rw-r--r-- | app/src/components/Dashboard.tsx | 204 | ||||
-rw-r--r-- | app/src/components/Login.tsx | 6 | ||||
-rw-r--r-- | app/src/components/Navbar.tsx | 2 | ||||
-rw-r--r-- | app/src/components/Signup.tsx | 7 | ||||
-rw-r--r-- | app/src/styles/Auth.module.css | 8 | ||||
-rw-r--r-- | app/src/styles/Create.module.css | 11 | ||||
-rw-r--r-- | app/src/styles/Dashboard.module.css | 29 |
11 files changed, 312 insertions, 100 deletions
diff --git a/api/routes/auth_routes.py b/api/routes/auth_routes.py index 24e1391..823feff 100644 --- a/api/routes/auth_routes.py +++ b/api/routes/auth_routes.py @@ -33,7 +33,7 @@ async def login_for_access_token( detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - access_token_expires = timedelta(minutes=1) + access_token_expires = timedelta(minutes=180) access_token = create_access_token( data={"sub": user.id, "username": user.username}, expires_delta=access_token_expires, diff --git a/api/routes/log_routes.py b/api/routes/log_routes.py index 84b8c51..5875e3b 100644 --- a/api/routes/log_routes.py +++ b/api/routes/log_routes.py @@ -21,6 +21,9 @@ async def get_logs( current_user: Annotated[User, Depends(get_current_user)], db=Depends(get_db), ): + """ + Get all of the logs associated with the current user + """ logs = ( db.query(Log) .filter(Log.owner == current_user.id) @@ -34,12 +37,47 @@ async def get_logs( return logs +@router.get("/{link}", summary="Get all logs for a specific link") +async def get_logs_for_link( + link: Annotated[str, Path(title="Link to get logs for")], + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + """ + Get all of the logs associated with a specific link + - check to make sure the requester is the owner + """ + link = ( + db.query(Link) + .filter(Link.owner == current_user.id, Link.short == link) + .first() + ) + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Link not found" + ) + logs = ( + db.query(Log) + .filter(Log.link_id == link.id) + .order_by(Log.timestamp.desc()) + .all() + ) + if not logs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="No logs found" + ) + return logs + + @router.get("/{log_id}", summary="Get a specific log") async def get_log( log_id: Annotated[int, Path(title="ID of log to delete")], current_user: Annotated[User, Depends(get_current_user)], db=Depends(get_db), ): + """ + Get a specific log (given the log ID) + """ log = ( db.query(Log) .filter(Log.id == log_id, Log.owner == current_user.id) @@ -58,6 +96,9 @@ async def delete_log( current_user: Annotated[User, Depends(get_current_user)], db=Depends(get_db), ): + """ + Delete a specific log (given the log ID) + """ log = ( db.query(Log) .filter(Log.id == log_id, Log.owner == current_user.id) diff --git a/app/src/App.tsx b/app/src/App.tsx index 54a500d..06ead3b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,6 +7,7 @@ import { import Login from './components/Login'; import Signup from './components/Signup'; import Dashboard from './components/Dashboard'; +import CreateLink from './components/CreateLink'; function App() { return ( @@ -16,6 +17,7 @@ function App() { <Route path="/login" element={<Login />} /> <Route path="/signup" element={<Signup />} /> <Route path="/dashboard" element={<Dashboard />} /> + <Route path="/create" element={<CreateLink />} /> </Routes> </Router> ); diff --git a/app/src/components/CreateLink.tsx b/app/src/components/CreateLink.tsx new file mode 100644 index 0000000..a6456f1 --- /dev/null +++ b/app/src/components/CreateLink.tsx @@ -0,0 +1,100 @@ +import { useState, FormEvent, useEffect } from 'react'; +import createStyles from '../styles/Create.module.css'; +import styles from '../styles/Auth.module.css'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import Navbar from './Navbar'; +import { useNavigate } from 'react-router-dom'; + +function CreateLink() { + document.title = 'LinkLogger | Create Short Link'; + + const [link, setLink] = useState(''); + const [url, setURL] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isCopied, setIsCopied] = useState(false); + const [error, setError] = useState<string | null>(null); + const navigate = useNavigate(); + + // Get /api/users/me to make sure the user is logged in, and + // to get the username for rendering on screen + useEffect(() => { + axios + .get('/api/users/me') + .then((res) => { + if (res.status != 200) { + navigate('/login'); + } + }) + .catch(() => { + navigate('/login'); + }); + }, []); + + const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setIsSubmitting(true); + try { + const res = await axios.post('/api/links', { url }); + if (res.status === 200) { + setLink(res.data.link); + } + } catch (error) { + setError('STRANGE'); + } + }; + + const copyLink = () => { + navigator.clipboard.writeText(`${window.location.origin}/c/${link}`); + setIsCopied(true); + // Wait 5 seconds, then set isCopied back to false + setTimeout(() => { + setIsCopied(false); + }, 5000); + }; + + return ( + <> + <Navbar /> + <div className={styles.container}> + <h1>Create a new short link by entering the long URL below</h1> + <p className={error ? styles.errorVisible : styles.errorHidden}> + {error} + </p> + <hr></hr> + <form onSubmit={handleSubmit}> + <input + className={createStyles.createInput} + type="text" + placeholder="Full URL" + value={url} + onChange={(e) => setURL(e.target.value)} + required + /> + {link.length === 0 ? ( + <button type="submit" disabled={isSubmitting}> + {isSubmitting ? 'Creating...' : 'Create'} + </button> + ) : ( + <button type="button" onClick={copyLink}> + {isCopied ? ( + <em>Copied!</em> + ) : ( + `Click to copy: ${window.location.origin}/c/${link}` + )} + </button> + )} + </form> + <hr></hr> + <p className={styles.footnote}> + <Link to="/dashboard" className={styles.footnoteLink}> + Click here + </Link>{' '} + to visit your dashboard. + </p> + </div> + </> + ); +} + +export default CreateLink; diff --git a/app/src/components/Dashboard.tsx b/app/src/components/Dashboard.tsx index f3442e0..1b728d1 100644 --- a/app/src/components/Dashboard.tsx +++ b/app/src/components/Dashboard.tsx @@ -31,6 +31,8 @@ function Dashboard() { const [links, setLinks] = useState<Link[]>([]); const [logs, setLogs] = useState<Log[]>([]); const [visibleLog, setVisibleLog] = useState<string | null>(null); + const [loadingLinks, setLoadingLinks] = useState<boolean>(true); // Track loading state for links + const [loadingLogs, setLoadingLogs] = useState<boolean>(true); // Track loading state for logs const navigate = useNavigate(); // Fetch links from API @@ -44,13 +46,15 @@ function Dashboard() { navigate('/login'); } }) - .catch((error: unknown) => { if (axios.isAxiosError(error)) { - if (error.response?.status != 404) { + if (error.response?.status !== 404) { navigate('/login'); } } + }) + .finally(() => { + setLoadingLinks(false); // Set loadingLinks to false once done }); }, []); @@ -67,10 +71,13 @@ function Dashboard() { }) .catch((error: unknown) => { if (axios.isAxiosError(error)) { - if (error.response?.status != 404) { + if (error.response?.status !== 404) { navigate('/login'); } } + }) + .finally(() => { + setLoadingLogs(false); // Set loadingLogs to false once done }); }, []); @@ -112,104 +119,117 @@ function Dashboard() { }); }; + // Loading spinner component + const LoadingSpinner = () => ( + <div className={styles.loadingSpinner}> + <div className={styles.spinner}></div> + <p>Loading...</p> + </div> + ); + return ( <> <Navbar /> - <table className={styles.mainTable}> - <thead> - <tr style={{ border: '2px solid #ccc' }}> - <th>Link</th> - <th>Visits</th> - <th>Redirect</th> - <th>Expire Date</th> - </tr> - </thead> - <tbody> - {/* If there are no links, put a special message */} - {links.length === 0 && ( - <tr> - <td colSpan={4}> - <div className={styles.noLinks}> - You do not have any shortened links - try creating one. - </div> - </td> + {/* Show loading spinner if either links or logs are still loading */} + {loadingLinks || loadingLogs ? ( + <LoadingSpinner /> + ) : ( + <table className={styles.mainTable}> + <thead> + <tr style={{ border: '2px solid #ccc' }}> + <th>Link</th> + <th>Visits</th> + <th>Redirect</th> + <th>Expire Date</th> </tr> - )} - - {/* For every link and its logs */} - {links.map((link) => ( - <React.Fragment key={link.link}> - <tr className={styles.linkTableRow}> - <td> - <button - onClick={() => toggleLogRow(link.link)} - className={styles.linkButton} - > - {link.link} - </button> - </td> - <td> - {logs.filter((log) => log.link === link.link).length || 0} + </thead> + <tbody> + {/* If there are no links, put a special message */} + {links.length === 0 && ( + <tr> + <td colSpan={4}> + <div className={styles.noLinks}> + You do not have any shortened links - try creating one. + </div> </td> - <td>{link.redirect_link}</td> - <td>{link.expire_date}</td> </tr> + )} - {/* Conditionally render logs for this link */} - {visibleLog === link.link && ( - <tr className={styles.logTableRow}> - <td colSpan={6}> - <table> - <thead> - <tr> - <th>ID</th> - <th>Timestamp</th> - <th>IP</th> - <th>Location</th> - <th colSpan={2}>ISP</th> - </tr> - </thead> - <tbody> - {/* Render all logs for the link */} - {logs - .filter((log) => log.link === link.link) - .map((log, index, filteredLogs) => ( - <tr key={log.id}> - <td>{filteredLogs.length - index}</td> - <td>{log.timestamp}</td> - <td>{log.ip}</td> - <td>{log.location}</td> - <td>{log.isp}</td> - <td> - <FontAwesomeIcon - icon={faTrash} - className={styles.trashBin} - id={log.id.toString()} - onClick={deleteLog} - /> - </td> - </tr> - ))} - {/* If the link has no logs, put a special message */} - {logs.filter((log) => log.link === link.link).length === - 0 && ( - <tr> - <td colSpan={6}> - <div className={styles.noLogs}> - No logs for this link - </div> - </td> - </tr> - )} - </tbody> - </table> + {/* For every link and its logs */} + {links.map((link) => ( + <React.Fragment key={link.link}> + <tr className={styles.linkTableRow}> + <td> + <button + onClick={() => toggleLogRow(link.link)} + className={styles.linkButton} + > + {link.link} + </button> </td> + <td> + {logs.filter((log) => log.link === link.link).length || 0} + </td> + <td>{link.redirect_link}</td> + <td>{link.expire_date}</td> </tr> - )} - </React.Fragment> - ))} - </tbody> - </table> + + {/* Conditionally render logs for this link */} + {visibleLog === link.link && ( + <tr className={styles.logTableRow}> + <td colSpan={6}> + <table> + <thead> + <tr> + <th>ID</th> + <th>Timestamp</th> + <th>IP</th> + <th>Location</th> + <th colSpan={2}>ISP</th> + </tr> + </thead> + <tbody> + {/* Render all logs for the link */} + {logs + .filter((log) => log.link === link.link) + .map((log, index, filteredLogs) => ( + <tr key={log.id}> + <td>{filteredLogs.length - index}</td> + <td>{log.timestamp}</td> + <td>{log.ip}</td> + <td>{log.location}</td> + <td>{log.isp}</td> + <td> + <FontAwesomeIcon + icon={faTrash} + className={styles.trashBin} + id={log.id.toString()} + onClick={deleteLog} + /> + </td> + </tr> + ))} + {/* If the link has no logs, put a special message */} + {logs.filter((log) => log.link === link.link) + .length === 0 && ( + <tr> + <td colSpan={6}> + <div className={styles.noLogs}> + No logs for this link + </div> + </td> + </tr> + )} + </tbody> + </table> + </td> + </tr> + )} + </React.Fragment> + ))} + </tbody> + </table> + )} </> ); } diff --git a/app/src/components/Login.tsx b/app/src/components/Login.tsx index badb5b1..cc3a5bd 100644 --- a/app/src/components/Login.tsx +++ b/app/src/components/Login.tsx @@ -53,10 +53,13 @@ function Login() { <Navbar /> <div className={styles.container}> <h1>Log In</h1> - <h2 className={error ? 'errorVisible' : 'errorHidden'}>{error}</h2> + <p className={error ? styles.errorVisible : styles.errorHidden}> + {error} + </p> <hr></hr> <form onSubmit={handleSubmit}> <input + className={styles.authInput} type="text" placeholder="username" value={username} @@ -64,6 +67,7 @@ function Login() { required /> <input + className={styles.authInput} type="password" placeholder="password" value={password} diff --git a/app/src/components/Navbar.tsx b/app/src/components/Navbar.tsx index 53c1f52..ea49ea8 100644 --- a/app/src/components/Navbar.tsx +++ b/app/src/components/Navbar.tsx @@ -23,7 +23,7 @@ function Navbar() { }; checkAPIStatus(); - }); + }, []); return ( <div className={styles.navbar}> diff --git a/app/src/components/Signup.tsx b/app/src/components/Signup.tsx index f4a3368..c01784e 100644 --- a/app/src/components/Signup.tsx +++ b/app/src/components/Signup.tsx @@ -66,10 +66,13 @@ function Signup() { <Navbar /> <div className={styles.container}> <h1>Sign up</h1> - <h2 className={error ? 'errorVisible' : 'errorHidden'}>{error}</h2> + <h2 className={error ? styles.errorVisible : styles.errorHidden}> + {error} + </h2> <hr></hr> <form onSubmit={handleSubmit}> <input + className={styles.authInput} type="text" placeholder="username" value={username} @@ -77,6 +80,7 @@ function Signup() { required /> <input + className={styles.authInput} type="password" placeholder="password" value={password} @@ -85,6 +89,7 @@ function Signup() { required /> <input + className={styles.authInput} type="password" placeholder="confirm password" value={passwordConfirm} diff --git a/app/src/styles/Auth.module.css b/app/src/styles/Auth.module.css index 7e1d3e3..ad54c5a 100644 --- a/app/src/styles/Auth.module.css +++ b/app/src/styles/Auth.module.css @@ -16,7 +16,6 @@ body { h1 { color: #ccc; - font-size: 30px; font-weight: 600; border: 2px solid #606468; padding: 10px; @@ -24,16 +23,16 @@ h1 { margin: 0 auto; } -input { +.authInput { display: block; margin: 10px auto; width: 300px; border-radius: 5px; padding: 15px; + font-size: 17px; color: #ccc; background-color: #3b4148; border: none; - font-size: 17px; } button { @@ -42,10 +41,10 @@ button { width: 100%; border-radius: 5px; padding: 15px; + font-size: 17px; color: #ccc; background-color: #415eac; border: none; - font-size: 17px; cursor: pointer; transition: background-color 0.2s ease, transform 0.3s ease; } @@ -61,6 +60,7 @@ button:active { .errorVisible { visibility: visible; color: #ee6161; + font-weight: 600; } .errorHidden { diff --git a/app/src/styles/Create.module.css b/app/src/styles/Create.module.css new file mode 100644 index 0000000..2d21514 --- /dev/null +++ b/app/src/styles/Create.module.css @@ -0,0 +1,11 @@ +.createInput { + display: block; + margin: 10px auto; + width: 500px; + border-radius: 5px; + padding: 15px; + color: #ccc; + background-color: #3b4148; + border: none; + font-size: 17px; +}
\ No newline at end of file diff --git a/app/src/styles/Dashboard.module.css b/app/src/styles/Dashboard.module.css index 96ed919..7f332c7 100644 --- a/app/src/styles/Dashboard.module.css +++ b/app/src/styles/Dashboard.module.css @@ -5,6 +5,35 @@ body { background-color: #2c3338; } +.loadingSpinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +} + +.spinner { + border: 4px solid #ccc; + border-top: 4px solid #415eac; + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loadingSpinner p { + margin-top: 20px; + color: #415eac; + font-weight: bold; +} + + table { margin: 0 auto; text-align: center; |