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",
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,

View File

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

View File

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

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 [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>
</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>
{logs.filter((log) => log.link === link.link).length || 0}
</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}
/>
{/* 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>
{/* 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>
))}
{/* 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>
)}
</tbody>
</table>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

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

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