Extra route + Much more UI

This commit is contained in:
Parker M. 2024-11-15 00:30:43 -06:00
parent dbc53a555e
commit 04cc3869c2
Signed by: parker
GPG Key ID: 505ED36FC12B5D5E
11 changed files with 312 additions and 100 deletions

View File

@ -33,7 +33,7 @@ async def login_for_access_token(
detail="Incorrect username or password", detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
access_token_expires = timedelta(minutes=1) access_token_expires = timedelta(minutes=180)
access_token = create_access_token( access_token = create_access_token(
data={"sub": user.id, "username": user.username}, data={"sub": user.id, "username": user.username},
expires_delta=access_token_expires, expires_delta=access_token_expires,

View File

@ -21,6 +21,9 @@ async def get_logs(
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
"""
Get all of the logs associated with the current user
"""
logs = ( logs = (
db.query(Log) db.query(Log)
.filter(Log.owner == current_user.id) .filter(Log.owner == current_user.id)
@ -34,12 +37,47 @@ async def get_logs(
return 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") @router.get("/{log_id}", summary="Get a specific log")
async def get_log( async def get_log(
log_id: Annotated[int, Path(title="ID of log to delete")], log_id: Annotated[int, Path(title="ID of log to delete")],
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
"""
Get a specific log (given the log ID)
"""
log = ( log = (
db.query(Log) db.query(Log)
.filter(Log.id == log_id, Log.owner == current_user.id) .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)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
"""
Delete a specific log (given the log ID)
"""
log = ( log = (
db.query(Log) db.query(Log)
.filter(Log.id == log_id, Log.owner == current_user.id) .filter(Log.id == log_id, Log.owner == current_user.id)

View File

@ -7,6 +7,7 @@ import {
import Login from './components/Login'; import Login from './components/Login';
import Signup from './components/Signup'; import Signup from './components/Signup';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import CreateLink from './components/CreateLink';
function App() { function App() {
return ( return (
@ -16,6 +17,7 @@ function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} /> <Route path="/signup" element={<Signup />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/create" element={<CreateLink />} />
</Routes> </Routes>
</Router> </Router>
); );

View File

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

View File

@ -31,6 +31,8 @@ function Dashboard() {
const [links, setLinks] = useState<Link[]>([]); const [links, setLinks] = useState<Link[]>([]);
const [logs, setLogs] = useState<Log[]>([]); const [logs, setLogs] = useState<Log[]>([]);
const [visibleLog, setVisibleLog] = useState<string | null>(null); 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(); const navigate = useNavigate();
// Fetch links from API // Fetch links from API
@ -44,13 +46,15 @@ function Dashboard() {
navigate('/login'); navigate('/login');
} }
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
if (error.response?.status != 404) { if (error.response?.status !== 404) {
navigate('/login'); navigate('/login');
} }
} }
})
.finally(() => {
setLoadingLinks(false); // Set loadingLinks to false once done
}); });
}, []); }, []);
@ -67,10 +71,13 @@ function Dashboard() {
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
if (error.response?.status != 404) { if (error.response?.status !== 404) {
navigate('/login'); 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 ( return (
<> <>
<Navbar /> <Navbar />
<table className={styles.mainTable}> {/* Show loading spinner if either links or logs are still loading */}
<thead> {loadingLinks || loadingLogs ? (
<tr style={{ border: '2px solid #ccc' }}> <LoadingSpinner />
<th>Link</th> ) : (
<th>Visits</th> <table className={styles.mainTable}>
<th>Redirect</th> <thead>
<th>Expire Date</th> <tr style={{ border: '2px solid #ccc' }}>
</tr> <th>Link</th>
</thead> <th>Visits</th>
<tbody> <th>Redirect</th>
{/* If there are no links, put a special message */} <th>Expire Date</th>
{links.length === 0 && (
<tr>
<td colSpan={4}>
<div className={styles.noLinks}>
You do not have any shortened links - try creating one.
</div>
</td>
</tr> </tr>
)} </thead>
<tbody>
{/* For every link and its logs */} {/* If there are no links, put a special message */}
{links.map((link) => ( {links.length === 0 && (
<React.Fragment key={link.link}> <tr>
<tr className={styles.linkTableRow}> <td colSpan={4}>
<td> <div className={styles.noLinks}>
<button You do not have any shortened links - try creating one.
onClick={() => toggleLogRow(link.link)} </div>
className={styles.linkButton}
>
{link.link}
</button>
</td> </td>
<td>
{logs.filter((log) => log.link === link.link).length || 0}
</td>
<td>{link.redirect_link}</td>
<td>{link.expire_date}</td>
</tr> </tr>
)}
{/* Conditionally render logs for this link */} {/* For every link and its logs */}
{visibleLog === link.link && ( {links.map((link) => (
<tr className={styles.logTableRow}> <React.Fragment key={link.link}>
<td colSpan={6}> <tr className={styles.linkTableRow}>
<table> <td>
<thead> <button
<tr> onClick={() => toggleLogRow(link.link)}
<th>ID</th> className={styles.linkButton}
<th>Timestamp</th> >
<th>IP</th> {link.link}
<th>Location</th> </button>
<th colSpan={2}>ISP</th> </td>
</tr> <td>
</thead> {logs.filter((log) => log.link === link.link).length || 0}
<tbody> </td>
{/* Render all logs for the link */} <td>{link.redirect_link}</td>
{logs <td>{link.expire_date}</td>
.filter((log) => log.link === link.link) </tr>
.map((log, index, filteredLogs) => (
<tr key={log.id}> {/* Conditionally render logs for this link */}
<td>{filteredLogs.length - index}</td> {visibleLog === link.link && (
<td>{log.timestamp}</td> <tr className={styles.logTableRow}>
<td>{log.ip}</td> <td colSpan={6}>
<td>{log.location}</td> <table>
<td>{log.isp}</td> <thead>
<td> <tr>
<FontAwesomeIcon <th>ID</th>
icon={faTrash} <th>Timestamp</th>
className={styles.trashBin} <th>IP</th>
id={log.id.toString()} <th>Location</th>
onClick={deleteLog} <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> </td>
</tr> </tr>
))} )}
{/* If the link has no logs, put a special message */} </tbody>
{logs.filter((log) => log.link === link.link).length === </table>
0 && ( </td>
<tr> </tr>
<td colSpan={6}> )}
<div className={styles.noLogs}> </React.Fragment>
No logs for this link ))}
</div> </tbody>
</td> </table>
</tr> )}
)}
</tbody>
</table>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</> </>
); );
} }

View File

@ -53,10 +53,13 @@ function Login() {
<Navbar /> <Navbar />
<div className={styles.container}> <div className={styles.container}>
<h1>Log In</h1> <h1>Log In</h1>
<h2 className={error ? 'errorVisible' : 'errorHidden'}>{error}</h2> <p className={error ? styles.errorVisible : styles.errorHidden}>
{error}
</p>
<hr></hr> <hr></hr>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<input <input
className={styles.authInput}
type="text" type="text"
placeholder="username" placeholder="username"
value={username} value={username}
@ -64,6 +67,7 @@ function Login() {
required required
/> />
<input <input
className={styles.authInput}
type="password" type="password"
placeholder="password" placeholder="password"
value={password} value={password}

View File

@ -23,7 +23,7 @@ function Navbar() {
}; };
checkAPIStatus(); checkAPIStatus();
}); }, []);
return ( return (
<div className={styles.navbar}> <div className={styles.navbar}>

View File

@ -66,10 +66,13 @@ function Signup() {
<Navbar /> <Navbar />
<div className={styles.container}> <div className={styles.container}>
<h1>Sign up</h1> <h1>Sign up</h1>
<h2 className={error ? 'errorVisible' : 'errorHidden'}>{error}</h2> <h2 className={error ? styles.errorVisible : styles.errorHidden}>
{error}
</h2>
<hr></hr> <hr></hr>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<input <input
className={styles.authInput}
type="text" type="text"
placeholder="username" placeholder="username"
value={username} value={username}
@ -77,6 +80,7 @@ function Signup() {
required required
/> />
<input <input
className={styles.authInput}
type="password" type="password"
placeholder="password" placeholder="password"
value={password} value={password}
@ -85,6 +89,7 @@ function Signup() {
required required
/> />
<input <input
className={styles.authInput}
type="password" type="password"
placeholder="confirm password" placeholder="confirm password"
value={passwordConfirm} value={passwordConfirm}

View File

@ -16,7 +16,6 @@ body {
h1 { h1 {
color: #ccc; color: #ccc;
font-size: 30px;
font-weight: 600; font-weight: 600;
border: 2px solid #606468; border: 2px solid #606468;
padding: 10px; padding: 10px;
@ -24,16 +23,16 @@ h1 {
margin: 0 auto; margin: 0 auto;
} }
input { .authInput {
display: block; display: block;
margin: 10px auto; margin: 10px auto;
width: 300px; width: 300px;
border-radius: 5px; border-radius: 5px;
padding: 15px; padding: 15px;
font-size: 17px;
color: #ccc; color: #ccc;
background-color: #3b4148; background-color: #3b4148;
border: none; border: none;
font-size: 17px;
} }
button { button {
@ -42,10 +41,10 @@ button {
width: 100%; width: 100%;
border-radius: 5px; border-radius: 5px;
padding: 15px; padding: 15px;
font-size: 17px;
color: #ccc; color: #ccc;
background-color: #415eac; background-color: #415eac;
border: none; border: none;
font-size: 17px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, transform 0.3s ease; transition: background-color 0.2s ease, transform 0.3s ease;
} }
@ -61,6 +60,7 @@ button:active {
.errorVisible { .errorVisible {
visibility: visible; visibility: visible;
color: #ee6161; color: #ee6161;
font-weight: 600;
} }
.errorHidden { .errorHidden {

View File

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

View File

@ -5,6 +5,35 @@ body {
background-color: #2c3338; 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 { table {
margin: 0 auto; margin: 0 auto;
text-align: center; text-align: center;