client/server: search cleanup, admin work
This commit is contained in:
parent
7505bb43fe
commit
6afc4c915e
11 changed files with 186 additions and 23 deletions
|
@ -12,3 +12,14 @@
|
|||
color: var(--gray-dark);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.postModal details {
|
||||
border-radius: var(--radius);
|
||||
padding: var(--gap);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.postModal summary {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { Text, Fieldset, Spacer, Link } from '@geist-ui/core'
|
||||
import getPostPath from '@lib/get-post-path'
|
||||
import { Post, User } from '@lib/types'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import styles from './admin.module.css'
|
||||
const fetcher = (url: string) => fetch(url, {
|
||||
import PostModal from './post-modal-link'
|
||||
|
||||
export const adminFetcher = (url: string) => fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -13,9 +15,21 @@ const fetcher = (url: string) => fetch(url, {
|
|||
}).then(res => res.json())
|
||||
|
||||
const Admin = () => {
|
||||
const { data: posts, error } = useSWR<Post[]>('/server-api/admin/posts', fetcher)
|
||||
const { data: users, error: errorUsers } = useSWR<User[]>('/server-api/admin/users', fetcher)
|
||||
console.log(posts, error)
|
||||
const { data: posts, error: postsError } = useSWR<Post[]>('/server-api/admin/posts', adminFetcher)
|
||||
const { data: users, error: usersError } = useSWR<User[]>('/server-api/admin/users', adminFetcher)
|
||||
const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({})
|
||||
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
|
||||
useEffect(() => {
|
||||
if (posts) {
|
||||
// sum the sizes of each file per post
|
||||
const sizes = posts.reduce((acc, post) => {
|
||||
const size = post.files.reduce((acc, file) => acc + file.html.length, 0)
|
||||
return { ...acc, [post.id]: byteToMB(size) }
|
||||
}, {})
|
||||
setPostSizes(sizes)
|
||||
}
|
||||
}, [posts])
|
||||
|
||||
return (
|
||||
<div className={styles.adminWrapper}>
|
||||
<Text h2>Administration</Text>
|
||||
|
@ -23,6 +37,7 @@ const Admin = () => {
|
|||
<Fieldset.Title>Users</Fieldset.Title>
|
||||
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
|
||||
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
||||
{users && <table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -50,6 +65,7 @@ const Admin = () => {
|
|||
<Fieldset.Title>Posts</Fieldset.Title>
|
||||
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
|
||||
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
||||
{posts && <table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -57,19 +73,24 @@ const Admin = () => {
|
|||
<th>Visibility</th>
|
||||
<th>Created</th>
|
||||
<th>Author</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts?.map(post => (
|
||||
{posts?.map((post, i) => (
|
||||
<tr key={post.id}>
|
||||
<td><Link color href={getPostPath(post.visibility, post.id)}>{post.title}</Link></td>
|
||||
<td><PostModal id={post.id} /></td>
|
||||
<td>{post.visibility}</td>
|
||||
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td>
|
||||
<td>{post.users ? post.users[0].username : ''}</td>
|
||||
<td>{post.users?.length ? post.users[0].username : <i>Deleted</i>}</td>
|
||||
<td>{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>}
|
||||
{Object.keys(postSizes).length && <div style={{ float: 'right' }}>
|
||||
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text>
|
||||
</div>}
|
||||
</Fieldset>
|
||||
|
||||
</div >
|
||||
|
|
51
client/components/admin/post-modal-link.tsx
Normal file
51
client/components/admin/post-modal-link.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { Link, Modal, useModal } from "@geist-ui/core";
|
||||
import { Post } from "@lib/types";
|
||||
import Cookies from "js-cookie";
|
||||
import useSWR from "swr";
|
||||
import { adminFetcher } from ".";
|
||||
import styles from './admin.module.css'
|
||||
|
||||
const PostModal = ({ id }: {
|
||||
id: string,
|
||||
}) => {
|
||||
const { visible, setVisible, bindings } = useModal()
|
||||
const { data: post, error } = useSWR<Post>(`/server-api/admin/post/${id}`, adminFetcher)
|
||||
if (error) return <Modal>failed to load</Modal>
|
||||
if (!post) return <Modal>loading...</Modal>
|
||||
|
||||
const deletePost = async () => {
|
||||
await fetch(`/server-api/admin/post/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
||||
}
|
||||
})
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link href="#" color onClick={() => setVisible(true)}>{post.title}</Link>
|
||||
<Modal width={'var(--main-content)'} {...bindings}>
|
||||
<Modal.Title>{post.title}</Modal.Title>
|
||||
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
|
||||
{post.files.map((file) => (
|
||||
<div key={file.id} className={styles.postModal}>
|
||||
<Modal.Content>
|
||||
<details>
|
||||
<summary>{file.title}</summary>
|
||||
<div dangerouslySetInnerHTML={{ __html: file.html }}>
|
||||
</div>
|
||||
</details>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Modal.Action type="warning" onClick={deletePost}>Delete</Modal.Action>
|
||||
<Modal.Action passive onClick={() => setVisible(false)}>Close</Modal.Action>
|
||||
</Modal>
|
||||
</>)
|
||||
}
|
||||
|
||||
export default PostModal
|
|
@ -40,6 +40,7 @@ const Header = () => {
|
|||
const userData = useUserData();
|
||||
const [pages, setPages] = useState<Tab[]>([])
|
||||
const { setTheme, theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setBodyHidden(expanded)
|
||||
}, [expanded, setBodyHidden])
|
||||
|
|
|
@ -33,12 +33,14 @@ function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) {
|
|||
}
|
||||
|
||||
const validator = (file: File) => {
|
||||
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
|
||||
|
||||
// TODO: make this configurable
|
||||
const maxFileSize = 50000000;
|
||||
if (file.size > maxFileSize) {
|
||||
return {
|
||||
code: 'file-too-big',
|
||||
message: 'File is too big. Maximum file size is ' + (maxFileSize).toFixed(2) + ' MB.',
|
||||
message: 'File is too big. Maximum file size is ' + byteToMB(maxFileSize) + ' MB.',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -117,7 +117,7 @@ const PostList = ({ morePosts, initialPosts, error }: Props) => {
|
|||
<ListItemSkeleton />
|
||||
</li>
|
||||
</ul>}
|
||||
{posts?.length === 0 && !error && <Text type='secondary'>No posts found.Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
||||
{posts?.length === 0 && !error && <Text type='secondary'>No posts found. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
||||
{
|
||||
posts?.length > 0 && <div>
|
||||
<ul>
|
||||
|
|
|
@ -20,12 +20,12 @@ services:
|
|||
context: ./client
|
||||
network: host
|
||||
args:
|
||||
API_URL: http://server:3000
|
||||
API_URL: http://localhost:3000
|
||||
SECRET_KEY: secret
|
||||
restart: unless-stopped
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- API_URL=http://server:3000
|
||||
- API_URL=http://localhost:3000
|
||||
- SECRET_KEY=secret
|
||||
ports:
|
||||
- "3001:3001"
|
||||
|
|
BIN
drift.sqlite
BIN
drift.sqlite
Binary file not shown.
|
@ -46,7 +46,7 @@ admin.get("/posts", async (req, res, next) => {
|
|||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"]
|
||||
attributes: ["id", "title", "createdAt", "html"]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
|
@ -61,3 +61,55 @@ admin.get("/posts", async (req, res, next) => {
|
|||
}
|
||||
})
|
||||
|
||||
admin.get("/post/:id", async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id, {
|
||||
attributes: {
|
||||
exclude: ["content"],
|
||||
include: ["id", "title", "visibility", "createdAt"]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "sha", "createdAt", "updatedAt", "html"]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id", "username"]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
message: "Post not found"
|
||||
})
|
||||
}
|
||||
|
||||
res.json(post)
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
admin.delete("/post/:id", async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id)
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
message: "Post not found"
|
||||
})
|
||||
}
|
||||
|
||||
if (post.files?.length)
|
||||
await Promise.all(post.files.map((file) => file.destroy()))
|
||||
await post.destroy({ force: true })
|
||||
res.json({
|
||||
message: "Post deleted"
|
||||
})
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ auth.post(
|
|||
const user = {
|
||||
username: username as string,
|
||||
password: await hash(req.body.password, salt),
|
||||
role: (!process.env.MEMORY_DB && process.env.ENABLE_ADMIN && count === 0) ? "admin" : "user"
|
||||
role: (!!process.env.MEMORY_DB && process.env.ENABLE_ADMIN && count === 0) ? "admin" : "user"
|
||||
}
|
||||
|
||||
const created_user = await User.create(user)
|
||||
|
|
|
@ -8,6 +8,7 @@ import { User } from "@lib/models/User"
|
|||
import secretKey from "@lib/middleware/secret-key"
|
||||
import markdown from "@lib/render-markdown"
|
||||
import { Op } from "sequelize"
|
||||
import { PostAuthor } from "@lib/models/PostAuthor"
|
||||
|
||||
export const posts = Router()
|
||||
|
||||
|
@ -168,7 +169,7 @@ posts.get(
|
|||
q: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
const { q } = req.query
|
||||
if (typeof q !== "string") {
|
||||
return res.status(400).json({ error: "Invalid query" })
|
||||
|
@ -181,6 +182,9 @@ posts.get(
|
|||
{ title: { [Op.like]: `%${q}%` } },
|
||||
{ "$files.title$": { [Op.like]: `%${q}%` } },
|
||||
{ "$files.content$": { [Op.like]: `%${q}%` } }
|
||||
],
|
||||
[Op.and]: [
|
||||
{ "$users.id$": req.user?.id || "" },
|
||||
]
|
||||
},
|
||||
include: [
|
||||
|
@ -188,9 +192,13 @@ posts.get(
|
|||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title"]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
}
|
||||
],
|
||||
attributes: ["id", "title", "visibility", "createdAt"],
|
||||
attributes: ["id", "title", "visibility", "createdAt", "deletedAt"],
|
||||
order: [["createdAt", "DESC"]]
|
||||
})
|
||||
|
||||
|
@ -273,19 +281,36 @@ posts.get(
|
|||
}
|
||||
)
|
||||
|
||||
posts.delete("/:id", jwt, async (req, res, next) => {
|
||||
posts.delete("/:id", jwt, async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id)
|
||||
const post = await Post.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id"]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: "Post not found" })
|
||||
}
|
||||
|
||||
jwt(req as UserJwtRequest, res, async () => {
|
||||
|
||||
if (req.user?.id !== post.users![0].id) {
|
||||
return res.status(403).json({ error: "Forbidden" })
|
||||
}
|
||||
if (post.files?.length)
|
||||
await Promise.all(post.files.map((file) => file.destroy()))
|
||||
|
||||
const postAuthor = await PostAuthor.findOne({
|
||||
where: {
|
||||
postId: post.id
|
||||
}
|
||||
})
|
||||
if (postAuthor) await postAuthor.destroy()
|
||||
await post.destroy()
|
||||
res.json({ message: "Post deleted" })
|
||||
})
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue