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);
|
color: var(--gray-dark);
|
||||||
font-weight: bold;
|
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 { Text, Fieldset, Spacer, Link } from '@geist-ui/core'
|
||||||
import getPostPath from '@lib/get-post-path'
|
|
||||||
import { Post, User } from '@lib/types'
|
import { Post, User } from '@lib/types'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import styles from './admin.module.css'
|
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",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
@ -13,9 +15,21 @@ const fetcher = (url: string) => fetch(url, {
|
||||||
}).then(res => res.json())
|
}).then(res => res.json())
|
||||||
|
|
||||||
const Admin = () => {
|
const Admin = () => {
|
||||||
const { data: posts, error } = useSWR<Post[]>('/server-api/admin/posts', fetcher)
|
const { data: posts, error: postsError } = useSWR<Post[]>('/server-api/admin/posts', adminFetcher)
|
||||||
const { data: users, error: errorUsers } = useSWR<User[]>('/server-api/admin/users', fetcher)
|
const { data: users, error: usersError } = useSWR<User[]>('/server-api/admin/users', adminFetcher)
|
||||||
console.log(posts, error)
|
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 (
|
return (
|
||||||
<div className={styles.adminWrapper}>
|
<div className={styles.adminWrapper}>
|
||||||
<Text h2>Administration</Text>
|
<Text h2>Administration</Text>
|
||||||
|
@ -23,6 +37,7 @@ const Admin = () => {
|
||||||
<Fieldset.Title>Users</Fieldset.Title>
|
<Fieldset.Title>Users</Fieldset.Title>
|
||||||
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
|
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
|
||||||
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||||
|
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
||||||
{users && <table>
|
{users && <table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -50,6 +65,7 @@ const Admin = () => {
|
||||||
<Fieldset.Title>Posts</Fieldset.Title>
|
<Fieldset.Title>Posts</Fieldset.Title>
|
||||||
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
|
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
|
||||||
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||||
|
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
||||||
{posts && <table>
|
{posts && <table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -57,19 +73,24 @@ const Admin = () => {
|
||||||
<th>Visibility</th>
|
<th>Visibility</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Author</th>
|
<th>Author</th>
|
||||||
|
<th>Size</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{posts?.map(post => (
|
{posts?.map((post, i) => (
|
||||||
<tr key={post.id}>
|
<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>{post.visibility}</td>
|
||||||
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>}
|
</table>}
|
||||||
|
{Object.keys(postSizes).length && <div style={{ float: 'right' }}>
|
||||||
|
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text>
|
||||||
|
</div>}
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
</div >
|
</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 userData = useUserData();
|
||||||
const [pages, setPages] = useState<Tab[]>([])
|
const [pages, setPages] = useState<Tab[]>([])
|
||||||
const { setTheme, theme } = useTheme()
|
const { setTheme, theme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBodyHidden(expanded)
|
setBodyHidden(expanded)
|
||||||
}, [expanded, setBodyHidden])
|
}, [expanded, setBodyHidden])
|
||||||
|
|
|
@ -33,12 +33,14 @@ function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const validator = (file: File) => {
|
const validator = (file: File) => {
|
||||||
|
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
|
||||||
|
|
||||||
// TODO: make this configurable
|
// TODO: make this configurable
|
||||||
const maxFileSize = 50000000;
|
const maxFileSize = 50000000;
|
||||||
if (file.size > maxFileSize) {
|
if (file.size > maxFileSize) {
|
||||||
return {
|
return {
|
||||||
code: 'file-too-big',
|
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 />
|
<ListItemSkeleton />
|
||||||
</li>
|
</li>
|
||||||
</ul>}
|
</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>
|
posts?.length > 0 && <div>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
@ -20,12 +20,12 @@ services:
|
||||||
context: ./client
|
context: ./client
|
||||||
network: host
|
network: host
|
||||||
args:
|
args:
|
||||||
API_URL: http://server:3000
|
API_URL: http://localhost:3000
|
||||||
SECRET_KEY: secret
|
SECRET_KEY: secret
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: 1000:1000
|
user: 1000:1000
|
||||||
environment:
|
environment:
|
||||||
- API_URL=http://server:3000
|
- API_URL=http://localhost:3000
|
||||||
- SECRET_KEY=secret
|
- SECRET_KEY=secret
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "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,
|
model: File,
|
||||||
as: "files",
|
as: "files",
|
||||||
attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"]
|
attributes: ["id", "title", "createdAt", "html"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
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 = {
|
const user = {
|
||||||
username: username as string,
|
username: username as string,
|
||||||
password: await hash(req.body.password, salt),
|
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)
|
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 secretKey from "@lib/middleware/secret-key"
|
||||||
import markdown from "@lib/render-markdown"
|
import markdown from "@lib/render-markdown"
|
||||||
import { Op } from "sequelize"
|
import { Op } from "sequelize"
|
||||||
|
import { PostAuthor } from "@lib/models/PostAuthor"
|
||||||
|
|
||||||
export const posts = Router()
|
export const posts = Router()
|
||||||
|
|
||||||
|
@ -168,7 +169,7 @@ posts.get(
|
||||||
q: Joi.string().required()
|
q: Joi.string().required()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req: UserJwtRequest, res, next) => {
|
||||||
const { q } = req.query
|
const { q } = req.query
|
||||||
if (typeof q !== "string") {
|
if (typeof q !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid query" })
|
return res.status(400).json({ error: "Invalid query" })
|
||||||
|
@ -181,6 +182,9 @@ posts.get(
|
||||||
{ title: { [Op.like]: `%${q}%` } },
|
{ title: { [Op.like]: `%${q}%` } },
|
||||||
{ "$files.title$": { [Op.like]: `%${q}%` } },
|
{ "$files.title$": { [Op.like]: `%${q}%` } },
|
||||||
{ "$files.content$": { [Op.like]: `%${q}%` } }
|
{ "$files.content$": { [Op.like]: `%${q}%` } }
|
||||||
|
],
|
||||||
|
[Op.and]: [
|
||||||
|
{ "$users.id$": req.user?.id || "" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
|
@ -188,9 +192,13 @@ posts.get(
|
||||||
model: File,
|
model: File,
|
||||||
as: "files",
|
as: "files",
|
||||||
attributes: ["id", "title"]
|
attributes: ["id", "title"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "users",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
attributes: ["id", "title", "visibility", "createdAt"],
|
attributes: ["id", "title", "visibility", "createdAt", "deletedAt"],
|
||||||
order: [["createdAt", "DESC"]]
|
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 {
|
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) {
|
if (!post) {
|
||||||
return res.status(404).json({ error: "Post not found" })
|
return res.status(404).json({ error: "Post not found" })
|
||||||
}
|
}
|
||||||
|
|
||||||
jwt(req as UserJwtRequest, res, async () => {
|
|
||||||
if (post.files?.length)
|
if (req.user?.id !== post.users![0].id) {
|
||||||
await Promise.all(post.files.map((file) => file.destroy()))
|
return res.status(403).json({ error: "Forbidden" })
|
||||||
await post.destroy()
|
}
|
||||||
res.json({ message: "Post deleted" })
|
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) {
|
} catch (e) {
|
||||||
next(e)
|
next(e)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue