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",
|
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,
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
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 [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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
@ -23,7 +23,7 @@ function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
checkAPIStatus();
|
checkAPIStatus();
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.navbar}>
|
<div className={styles.navbar}>
|
||||||
|
@ -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}
|
||||||
|
@ -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 {
|
||||||
|
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;
|
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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user