Extra route + Much more UI
This commit is contained in:
parent
dbc53a555e
commit
04cc3869c2
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
);
|
||||
|
100
app/src/components/CreateLink.tsx
Normal file
100
app/src/components/CreateLink.tsx
Normal 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;
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -23,7 +23,7 @@ function Navbar() {
|
||||
};
|
||||
|
||||
checkAPIStatus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.navbar}>
|
||||
|
@ -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}
|
||||
|
@ -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 {
|
||||
|
11
app/src/styles/Create.module.css
Normal file
11
app/src/styles/Create.module.css
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user