This commit is contained in:
2022-03-31 12:06:31 +08:00
parent 75a366ae11
commit 7e4c106683
10 changed files with 1905 additions and 459 deletions

2
.gitignore vendored
View File

@@ -30,3 +30,5 @@ yarn-error.log*
# vercel
.vercel
/db.sqlite

82
libs/db.js Normal file
View File

@@ -0,0 +1,82 @@
import Database from "better-sqlite3";
const db = new Database("db.sqlite");
// init DB
db.prepare(
`CREATE TABLE IF NOT EXISTS time_ranges (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
range TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT ''
)`
).run();
db.prepare(
`CREATE TABLE IF NOT EXISTS configs (
name TEXT PRIMARY KEY,
value TEXT NOT NULL
)`
).run();
db.prepare(
`INSERT OR IGNORE INTO configs (name, value) VALUES ('limit', '1')`
).run();
db.prepare(
`INSERT OR IGNORE INTO configs (name, value) VALUES ('token', 'woshimima')`
).run();
// prepare statements
const insertTimeRange = db.prepare(
`INSERT INTO time_ranges (name, range) VALUES (?, ?)`
);
const getTimeRanges = db.prepare(`SELECT * FROM time_ranges`);
const deleteTimeRange = db.prepare(`DELETE FROM time_ranges WHERE id = ?`);
const updateUsername = db.prepare(
`UPDATE time_ranges SET username = ? WHERE id = ?`
);
const countUser = db.prepare(
`SELECT COUNT(*) as count FROM time_ranges WHERE username = ?`
);
const getUsername = db.prepare(`SELECT username FROM time_ranges WHERE id = ?`);
const updateUsernameWithLimit = db.transaction((username, id, limit) => {
const count = countUser.get(username).count;
const existingUsername = getUsername.get(id).username;
if (existingUsername !== "") {
throw new Error("Username already exists");
}
if (count >= limit) {
throw new Error("Limit reached");
}
updateUsername.run(username, id);
});
const getConfigStmt = db.prepare(`SELECT value FROM configs WHERE name = ?`);
const setConfigStmt = db.prepare(
`UPDATE configs SET value = ? WHERE name = ?`
);
const getLimit = () => {
const limit = getConfigStmt.get("limit").value;
return parseInt(limit);
};
const setLimit = (limit) => {
setConfigStmt.run(limit, "limit");
};
const getToken = () => {
const token = getConfigStmt.get("token").value;
return token;
};
const authenticate = (token) => {
const tokenFromDB = getToken();
return token === tokenFromDB;
};
export {
insertTimeRange,
getTimeRanges,
deleteTimeRange,
updateUsername,
updateUsernameWithLimit,
getLimit,
setLimit,
authenticate,
};

1659
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.5.1",
"@mui/material": "^5.5.3",
"better-sqlite3": "^7.5.0",
"next": "12.1.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"

View File

@@ -1,7 +1,63 @@
import '../styles/globals.css'
import Head from "next/head";
import Link from "next/link";
import { useState } from "react";
import {
Button,
Stack,
Box,
CssBaseline,
AppBar,
Toolbar,
Typography,
} from "@mui/material";
import "../styles/globals.css";
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
const [username, setUsername] = useState("");
pageProps = { ...pageProps, username, setUsername };
return (
<>
<Head>
<title>ITSC Tool</title>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
<CssBaseline />
</Head>
<AppBar
position="static"
sx={{
mb: 3,
}}
>
<Toolbar
sx={{
flex: 1,
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5">
<Link href="/">ITSC Tool</Link>
</Typography>
{username && (
<Button
variant="contained"
color="secondary"
onClick={() => {
//localStorage.removeItem("username");
setUsername("");
}}
>
{username} (Logout)
</Button>
)}
</Toolbar>
</AppBar>
<Component {...pageProps} />
</>
);
}
export default MyApp
export default MyApp;

30
pages/api/time/limit.js Normal file
View File

@@ -0,0 +1,30 @@
import { authenticate, setLimit } from "../../../libs/db";
export default function handler(req, res) {
// put method
if (req.method === "PUT") {
const { token, limit } = req.body;
// authenticate
if (!authenticate(token)) {
res.status(401).json({
error: "Unauthorized",
});
return;
}
// check type is integer
const limitInt = parseInt(limit);
if (!limitInt) {
res.status(400).json({
error: "limit must be integer",
});
return;
}
setLimit(limitInt);
res.status(200).send({ success: true });
} else {
res.status(405).send({ error: "method not allowed" });
}
}

29
pages/api/time/ranges.js Normal file
View File

@@ -0,0 +1,29 @@
import { authenticate, getTimeRanges, insertTimeRange } from "../../../libs/db";
export default function handler(req, res) {
// get method
if (req.method === "GET") {
res.setHeader("Cache-Control", "no-cache no-store must-revalidate");
res.status(200).json(getTimeRanges.all());
return;
} else if (req.method === "POST") {
// authenticate
const { token } = req.body;
if (!authenticate(token)) {
res.status(401).json({ error: "Unauthorized" });
return;
}
// jsonfiy
const { name, range } = req.body;
insertTimeRange.run(name, range);
res.status(200).json({
success: true,
});
return;
} else {
// 500 error
res.status(500).json({
error: "Method not allowed",
});
}
}

View File

@@ -0,0 +1,59 @@
import { deleteTimeRange, getLimit, authenticate, updateUsernameWithLimit } from "../../../../libs/db";
export default function handler(req, res) {
// check if id is valid
const { id } = req.query;
if (id === undefined) {
res.status(400).json({
error: "Missing id",
});
return;
}
// delete method
if (req.method === "DELETE") {
// authenticate
const { token } = req.body;
if (!authenticate(token)) {
console.log("[DELETE] Authentication failed");
res.status(401).json({
error: "Unauthenticated",
});
return;
}
deleteTimeRange.run(id);
// update username
} else if (req.method === "PUT") {
// check if id is valid
// check if username is valid
const { username } = req.body;
if (username === undefined) {
res.status(400).json({
error: "Missing username",
});
return;
}
try {
const limit = getLimit();
updateUsernameWithLimit(username, id, limit);
} catch (err) {
res.status(400).json({
error: err.message,
});
return;
}
// not allow
} else {
// 500 error
res.status(500).json({
error: "Method not allowed",
});
}
res.status(200).json({
success: true,
});
}

View File

@@ -1,69 +1,82 @@
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import {
Alert,
Button,
TextField,
Stack,
InputField,
Box,
Snackbar,
Container,
} from "@mui/material";
export default function Index(props) {
const [username, setUsername] = useState("");
const [snackbarOpen, setSnackbarOpen] = useState(false);
// get username from localStorage
useEffect(() => {
const localUsername = localStorage.getItem("username");
if (localUsername) {
setUsername(localUsername);
}
}, []);
const router = useRouter();
const login = () => {
if (!username) {
setSnackbarOpen(true);
return;
}
// set local storage
localStorage.setItem("username", username);
props.setUsername(username);
};
useEffect(() => {
if (props.username) {
router.push("/time");
}
}, [props.username]);
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.js</code>
</p>
<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h2>Documentation &rarr;</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className={styles.card}>
<h2>Learn &rarr;</h2>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/canary/examples"
className={styles.card}
<Container>
<Stack direction="row" spacing={2}>
<TextField
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyUp={(e) => {
if (e.key === "Enter") {
login();
}
}}
/>
<Link href="/time" passHref>
<Button
variant="contained"
color="primary"
onClick={(e) => {
e.preventDefault();
login();
}}
>
<h2>Examples &rarr;</h2>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h2>Deploy &rarr;</h2>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span className={styles.logo}>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
</footer>
</div>
)
Login
</Button>
</Link>
</Stack>
<Snackbar
open={snackbarOpen}
autoHideDuration={1000}
onClose={() => setSnackbarOpen(false)}
>
<Alert variant="filled" severity="error">
Username can{"'"}t be empty
</Alert>
</Snackbar>
</Container>
);
}

297
pages/time.js Normal file
View File

@@ -0,0 +1,297 @@
import {
Container,
Box,
Alert,
Snackbar,
Button,
TextField,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from "@mui/material";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
export default function Time(props) {
const [ranges, setRanges] = useState([]);
const [range, setRange] = useState("");
const [newName, setNewName] = useState("");
const [snackbarError, setSnackbarError] = useState(false);
const [snackbarErrorMessage, setSnackbarErrorMessage] = useState("");
const [snackbarSuccess, setSnackbarSuccess] = useState(false);
const [limit, setLimit] = useState(1);
const [token, setToken] = useState("");
const router = useRouter();
const isAdmin = () => {
if (props.username === "admin") {
return true;
} else {
return false;
}
};
const addRange = () => {
fetch("/api/time/ranges", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newName,
range,
token,
}),
}).then((res) =>
res.json().then((res) => {
if (res.error) {
setSnackbarError(true);
setSnackbarErrorMessage(res.error);
} else {
setSnackbarSuccess(true);
refreshRanges();
}
})
);
};
const refreshRanges = () => {
fetch("/api/time/ranges")
.then((res) => res.json())
.then((res) => {
if (res.error) {
setSnackbarError(true);
setSnackbarErrorMessage(res.error);
} else {
setSnackbarSuccess(true);
setRanges(res);
}
});
};
const deleteRange = (id) => {
fetch(`/api/time/ranges/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
})
.then((res) => res.json())
.then((res) => {
if (res.error) {
setSnackbarError(true);
setSnackbarErrorMessage(res.error);
} else {
setSnackbarSuccess(true);
refreshRanges();
}
});
};
const updateUsername = (id, username) => {
fetch(`/api/time/ranges/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
})
.then((res) => res.json())
.then((res) => {
if (res.error) {
setSnackbarError(true);
setSnackbarErrorMessage(res.error);
} else {
setSnackbarSuccess(true);
refreshRanges();
}
});
};
const updateLimit = (limit) => {
fetch("/api/time/limit", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ limit, token }),
})
.then((res) => res.json())
.then((res) => {
if (res.error) {
setSnackbarError(true);
setSnackbarErrorMessage(res.error);
} else {
setSnackbarSuccess(true);
refreshRanges();
}
});
};
useEffect(() => {
if (!props.username) {
router.push("/");
}
});
/*
useEffect(() => {
const interval = setInterval(() => {
refreshRanges();
}, 1000);
return () => clearInterval(interval);
}, []);
*/
useEffect(() => {
refreshRanges();
}, []);
return (
<Container>
{isAdmin() && (
<Box>
<Box
sx={{
my: 2,
}}
>
<TextField
label="Token"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<Box
sx={{
my: 2,
}}
>
<TextField
label="Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<TextField
label="Range"
value={range}
onChange={(e) => setRange(e.target.value)}
placeholder="2022-01-01 00:00:00"
/>
</Box>
<Button
variant="contained"
color="primary"
onClick={() => addRange()}
>
Add
</Button>
<Box
sx={{
my: 2,
}}
>
<TextField
label="Limit"
value={limit}
onChange={(e) => setLimit(e.target.value)}
/>
<Button
variant="contained"
color="primary"
onClick={() => {
updateLimit(limit);
}}
>
Update Limit
</Button>
</Box>
</Box>
</Box>
)}
<Button
variant="contained"
color="primary"
onClick={() => refreshRanges()}
>
Refresh
</Button>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Range</TableCell>
<TableCell>Taken</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ranges.map((range) => (
<TableRow key={range.id}>
<TableCell>{range.name}</TableCell>
<TableCell>{range.range}</TableCell>
<TableCell>{range.username}</TableCell>
<TableCell>
<Button
sx={{
userSelect: "none",
}}
disabled={range.username !== ""}
variant="contained"
color="primary"
onClick={() => updateUsername(range.id, props.username)}
>
Take
</Button>
{isAdmin() && (
<Button
sx={{
userSelect: "none",
}}
variant="contained"
color="secondary"
onClick={() => deleteRange(range.id)}
>
Delete
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Snackbar
open={snackbarError}
autoHideDuration={1000}
onClose={() => setSnackbarError(false)}
>
<Alert
variant="filled"
onClose={() => setSnackbarError(false)}
severity="error"
>
{snackbarErrorMessage}
</Alert>
</Snackbar>
<Snackbar
open={snackbarSuccess}
autoHideDuration={1000}
onClose={() => setSnackbarSuccess(false)}
>
<Alert
variant="filled"
onClose={() => setSnackbarSuccess(false)}
severity="success"
>
Success!
</Alert>
</Snackbar>
</Container>
);
}