aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorParker <contact@pkrm.dev>2024-11-15 00:30:43 -0600
committerParker <contact@pkrm.dev>2024-11-15 00:30:43 -0600
commit04cc3869c2844bb82ac6975ee218141104385c35 (patch)
tree1a4c9622606ea4e562a00f6fc022d7c802e3742d
parentdbc53a555e64fdd0b848bf33b4208820b8701509 (diff)
Extra route + Much more UI
-rw-r--r--api/routes/auth_routes.py2
-rw-r--r--api/routes/log_routes.py41
-rw-r--r--app/src/App.tsx2
-rw-r--r--app/src/components/CreateLink.tsx100
-rw-r--r--app/src/components/Dashboard.tsx204
-rw-r--r--app/src/components/Login.tsx6
-rw-r--r--app/src/components/Navbar.tsx2
-rw-r--r--app/src/components/Signup.tsx7
-rw-r--r--app/src/styles/Auth.module.css8
-rw-r--r--app/src/styles/Create.module.css11
-rw-r--r--app/src/styles/Dashboard.module.css29
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;