client/server: add post searching
This commit is contained in:
parent
9949faeebd
commit
951088bacf
16 changed files with 266 additions and 142 deletions
|
@ -28,8 +28,6 @@ You can change these to your liking.
|
|||
`client/.env`:
|
||||
|
||||
- `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify
|
||||
- `WELCOME_CONTENT`: a markdown string (with \n newlines) that's rendered on the home page
|
||||
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
||||
- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
|
||||
|
||||
`server/.env`:
|
||||
|
@ -40,6 +38,8 @@ You can change these to your liking.
|
|||
- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo.
|
||||
- `REGISTRATION_PASSWORD`: if MEMORY_DB is not `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no password will be required.
|
||||
- `SECRET_KEY`: the same secret key as the client
|
||||
- `WELCOME_CONTENT`: a markdown string that's rendered on the home page
|
||||
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
||||
|
||||
## Current status
|
||||
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
API_URL=http://localhost:3000
|
||||
WELCOME_TITLE="Welcome to Drift"
|
||||
WELCOME_CONTENT="## Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and password protected posts\n - Markdown is rendered and stored on the server\n - Syntax highlighting and automatic language detection\n - Drag-and-drop file uploading\n\n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\\\'t need for this demo). **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.** \n\nYou can find the source code on [GitHub](https://github.com/MaxLeiter/drift)."
|
||||
SECRET_KEY=secret
|
||||
|
|
|
@ -19,12 +19,11 @@ type Props = {
|
|||
setContent?: (content: string) => void
|
||||
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
initialTab?: "edit" | "preview"
|
||||
skeleton?: boolean
|
||||
remove?: () => void
|
||||
onPaste?: (e: any) => void
|
||||
}
|
||||
|
||||
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', skeleton, handleOnContentChange }: Props) => {
|
||||
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', handleOnContentChange }: Props) => {
|
||||
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
// const height = editable ? "500px" : '100%'
|
||||
|
@ -52,21 +51,21 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
|
|||
}
|
||||
}, [content])
|
||||
|
||||
if (skeleton) {
|
||||
return <>
|
||||
<Spacer height={1} />
|
||||
<div className={styles.card}>
|
||||
<div className={styles.fileNameContainer}>
|
||||
<Skeleton width={275} height={36} />
|
||||
{remove && <Skeleton width={36} height={36} />}
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
||||
<Skeleton width={'100%'} height={350} />
|
||||
</div >
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
// if (skeleton) {
|
||||
// return <>
|
||||
// <Spacer height={1} />
|
||||
// <div className={styles.card}>
|
||||
// <div className={styles.fileNameContainer}>
|
||||
// <Skeleton width={275} height={36} />
|
||||
// {remove && <Skeleton width={36} height={36} />}
|
||||
// </div>
|
||||
// <div className={styles.descriptionContainer}>
|
||||
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
||||
// <Skeleton width={'100%'} height={350} />
|
||||
// </div >
|
||||
// </div>
|
||||
// </>
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -112,7 +111,6 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
|
|||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
|
||||
</div >
|
||||
</div >
|
||||
</>
|
||||
|
|
|
@ -129,7 +129,7 @@ const Header = () => {
|
|||
|
||||
|
||||
return (
|
||||
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
|
||||
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={'var(--gap-double)'} paddingTop={"var(--gap)"}>
|
||||
<div className={styles.tabs}>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
|
|
3
client/components/home/home.module.css
Normal file
3
client/components/home/home.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.textarea {
|
||||
height: 100%;
|
||||
}
|
43
client/components/home/index.tsx
Normal file
43
client/components/home/index.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import ShiftBy from "@components/shift-by"
|
||||
import { Spacer, Tabs, Card, Textarea, Text } from "@geist-ui/core"
|
||||
import Image from 'next/image'
|
||||
import styles from './home.module.css'
|
||||
import markdownStyles from '@components/preview/preview.module.css'
|
||||
const Home = ({ introTitle, introContent, rendered }: {
|
||||
introTitle: string
|
||||
introContent: string
|
||||
rendered: string
|
||||
}) => {
|
||||
return (<><div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy>
|
||||
<Spacer />
|
||||
<Text style={{ display: 'inline' }} h1>{introTitle}</Text>
|
||||
</div>
|
||||
<Card>
|
||||
<Tabs initialValue={'preview'} hideDivider leftSpace={0}>
|
||||
<Tabs.Item label={"Raw"} value="edit">
|
||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={introContent}
|
||||
width="100%"
|
||||
// TODO: Textarea should grow to fill parent if height == 100%
|
||||
style={{ flex: 1, minHeight: 350 }}
|
||||
resize="vertical"
|
||||
className={styles.textarea}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
<Tabs.Item label="Preview" value="preview">
|
||||
<div style={{ marginTop: 'var(--gap-half)', }}>
|
||||
<article className={markdownStyles.markdownPreview} dangerouslySetInnerHTML={{ __html: rendered }} style={{
|
||||
height: "100%"
|
||||
}} />
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
</Card></>)
|
||||
}
|
||||
|
||||
export default Home
|
|
@ -1,7 +1,13 @@
|
|||
import type { Post } from "@lib/types"
|
||||
import PostList from "../post-list"
|
||||
|
||||
const MyPosts = ({ posts, error }: { posts: any, error: any }) => {
|
||||
return <PostList posts={posts} error={error} />
|
||||
const MyPosts = ({ posts, error, morePosts }:
|
||||
{
|
||||
posts: Post[],
|
||||
error: boolean,
|
||||
morePosts: boolean
|
||||
}) => {
|
||||
return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
|
||||
}
|
||||
|
||||
export default MyPosts
|
||||
|
|
|
@ -6,48 +6,59 @@ import styles from './post-list.module.css'
|
|||
import ListItemSkeleton from "./list-item-skeleton"
|
||||
import ListItem from "./list-item"
|
||||
import { Post } from "@lib/types"
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from "react"
|
||||
import { ChangeEvent, MouseEventHandler, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import debounce from "lodash.debounce"
|
||||
import Cookies from "js-cookie"
|
||||
|
||||
type Props = {
|
||||
posts: Post[]
|
||||
error: any
|
||||
initialPosts: Post[]
|
||||
error: boolean
|
||||
morePosts: boolean
|
||||
}
|
||||
|
||||
const PostList = ({ posts, error }: Props) => {
|
||||
const PostList = ({ morePosts, initialPosts, error }: Props) => {
|
||||
const [search, setSearchValue] = useState('')
|
||||
// const [searching, setSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<Post[]>(posts)
|
||||
const [posts, setPosts] = useState<Post[]>(initialPosts)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
|
||||
|
||||
const loadMoreClick = useCallback((e: React.MouseEvent<HTMLLinkElement>) => {
|
||||
e.preventDefault()
|
||||
if (hasMorePosts) {
|
||||
async function fetchPosts() {
|
||||
const res = await fetch(`/api/posts/mine&page=${posts.length / 10 + 1}`)
|
||||
const json = await res.json()
|
||||
setPosts([...posts, ...json.posts])
|
||||
setHasMorePosts(json.morePosts)
|
||||
}
|
||||
fetchPosts()
|
||||
}
|
||||
}, [posts, hasMorePosts])
|
||||
|
||||
// update posts on search
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
// support filters like "title is:private has:content" in the text
|
||||
// extract the filters
|
||||
const filters = search.split(' ').filter(s => s.includes(':'))
|
||||
const filtersMap = new Map<string, string>()
|
||||
filters.forEach(f => {
|
||||
const [key, value] = f.split(':')
|
||||
filtersMap.set(key, value)
|
||||
})
|
||||
|
||||
const results = posts.filter(post => {
|
||||
if (filtersMap.has('is') && filtersMap.get('is') !== post.visibility) {
|
||||
return false
|
||||
}
|
||||
for (const file of post.files) {
|
||||
if (file.content.toLowerCase().includes(search.toLowerCase())) {
|
||||
return true
|
||||
// fetch results from /server-api/posts/search
|
||||
const fetchResults = async () => {
|
||||
setSearching(true)
|
||||
//encode search
|
||||
const res = await fetch(`/server-api/posts/search?q=${encodeURIComponent(search)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
||||
// "tok": process.env.SECRET_KEY || ''
|
||||
}
|
||||
}
|
||||
return post.title.toLowerCase().includes(search.toLowerCase())
|
||||
})
|
||||
setSearchResults(results)
|
||||
|
||||
})
|
||||
const data = await res.json()
|
||||
setPosts(data)
|
||||
setSearching(false)
|
||||
}
|
||||
fetchResults()
|
||||
} else {
|
||||
setSearchResults(posts)
|
||||
setPosts(initialPosts)
|
||||
}
|
||||
}, [search, posts])
|
||||
}, [initialPosts, search])
|
||||
|
||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value)
|
||||
|
@ -68,33 +79,36 @@ const PostList = ({ posts, error }: Props) => {
|
|||
<div className={styles.searchContainer}>
|
||||
<Input scale={3 / 2}
|
||||
clearable
|
||||
placeholder="is:private"
|
||||
placeholder="Search..."
|
||||
onChange={debouncedSearchHandler} />
|
||||
<Text type="secondary">Available filters: <Code>is:visibility</Code></Text>
|
||||
</div>
|
||||
{error && <Text type='error'>Failed to load.</Text>}
|
||||
{!posts && searching && <ul>
|
||||
<li>
|
||||
<ListItemSkeleton />
|
||||
</li>
|
||||
<li>
|
||||
<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 && <Text>No posts returned. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
||||
{
|
||||
!posts && <ul>
|
||||
<li>
|
||||
<ListItemSkeleton />
|
||||
</li>
|
||||
<li>
|
||||
<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>}
|
||||
{searchResults.length === 0 && <Text>No posts returned. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
||||
{
|
||||
searchResults?.length > 0 && <div>
|
||||
posts?.length > 0 && <div>
|
||||
<ul>
|
||||
{searchResults.map((post) => {
|
||||
{posts.map((post) => {
|
||||
return <ListItem post={post} key={post.id} />
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div >
|
||||
{hasMorePosts && <div className={styles.moreContainer}>
|
||||
<Text type="secondary">
|
||||
<Link color onClick={loadMoreClick} href="">Load more</Link>
|
||||
</Text>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -30,6 +30,5 @@
|
|||
align-items: center;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: center;
|
||||
margin-top: var(--gap);
|
||||
margin-bottom: var(--gap-double);
|
||||
}
|
||||
|
|
|
@ -38,3 +38,7 @@
|
|||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
@ -117,12 +117,10 @@ const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props)
|
|||
</Tabs.Item>
|
||||
<Tabs.Item label="Preview" value="preview">
|
||||
<div style={{ marginTop: 'var(--gap-half)', }}>
|
||||
|
||||
<HtmlPreview height={height} fileId={id} />
|
||||
<HtmlPreview height={height} fileId={id} content={content} title={title} />
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
|
||||
</div >
|
||||
</div >
|
||||
</>
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
import styles from '@styles/Home.module.css'
|
||||
import Header from '@components/header'
|
||||
import Document from '@components/edit-document'
|
||||
import Image from 'next/image'
|
||||
import ShiftBy from '@components/shift-by'
|
||||
import PageSeo from '@components/page-seo'
|
||||
import { Page, Text, Spacer } from '@geist-ui/core'
|
||||
import HomeComponent from '@components/home'
|
||||
import { Page, Text, Spacer, Tabs, Textarea, Card } from '@geist-ui/core'
|
||||
|
||||
export function getStaticProps() {
|
||||
const introDoc = process.env.WELCOME_CONTENT
|
||||
export async function getStaticProps() {
|
||||
const resp = await fetch(process.env.API_URL + `/welcome`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": process.env.SECRET_KEY || ''
|
||||
}
|
||||
})
|
||||
|
||||
const { title, content, rendered } = await resp.json()
|
||||
return {
|
||||
props: {
|
||||
introContent: introDoc,
|
||||
introTitle: process.env.WELCOME_TITLE,
|
||||
introContent: content || null,
|
||||
rendered: rendered || null,
|
||||
introTitle: title || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,26 +27,18 @@ export function getStaticProps() {
|
|||
type Props = {
|
||||
introContent: string
|
||||
introTitle: string
|
||||
rendered: string
|
||||
}
|
||||
|
||||
const Home = ({ introContent, introTitle }: Props) => {
|
||||
const Home = ({ rendered, introContent, introTitle }: Props) => {
|
||||
return (
|
||||
<Page className={styles.container}>
|
||||
<Page className={styles.wrapper}>
|
||||
<PageSeo />
|
||||
<Page.Header>
|
||||
<Header />
|
||||
</Page.Header>
|
||||
<Page.Content className={styles.main}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy>
|
||||
<Spacer />
|
||||
<Text style={{ display: 'inline' }} h1> Welcome to Drift</Text>
|
||||
</div>
|
||||
<Document
|
||||
content={introContent}
|
||||
title={introTitle}
|
||||
initialTab={`preview`}
|
||||
/>
|
||||
<HomeComponent rendered={rendered} introContent={introContent} introTitle={introTitle} />
|
||||
</Page.Content>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -7,14 +7,14 @@ import type { GetServerSideProps } from 'next';
|
|||
import { Post } from '@lib/types';
|
||||
import { Page } from '@geist-ui/core';
|
||||
|
||||
const Home = ({ posts, error }: { posts: Post[]; error: any; }) => {
|
||||
const Home = ({ morePosts, posts, error }: { morePosts: boolean, posts: Post[]; error: boolean; }) => {
|
||||
return (
|
||||
<Page className={styles.container}>
|
||||
<Page className={styles.wrapper}>
|
||||
<Page.Header>
|
||||
<Header />
|
||||
</Page.Header>
|
||||
<Page.Content className={styles.main}>
|
||||
<MyPosts error={error} posts={posts} />
|
||||
<MyPosts morePosts={morePosts} error={error} posts={posts} />
|
||||
</Page.Content>
|
||||
</Page >
|
||||
)
|
||||
|
@ -31,7 +31,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
|
||||
const posts = await fetch(process.env.API_URL + `/posts/mine?page=1`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -49,10 +49,13 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const data = await posts.json()
|
||||
|
||||
return {
|
||||
props: {
|
||||
posts: await posts.json(),
|
||||
posts: data,
|
||||
error: posts.status !== 200,
|
||||
morePosts: data.length > 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Page } from '@geist-ui/core'
|
|||
|
||||
const New = () => {
|
||||
return (
|
||||
<Page className={styles.container} width="100%">
|
||||
<Page className={styles.wrapper}>
|
||||
<PageSeo title="Drift - New" />
|
||||
|
||||
<Page.Header>
|
||||
|
|
|
@ -1,30 +1,41 @@
|
|||
import * as express from "express"
|
||||
import * as bodyParser from "body-parser"
|
||||
import * as errorhandler from "strong-error-handler"
|
||||
import * as cors from "cors"
|
||||
import { posts, users, auth, files } from "@routes/index"
|
||||
import { errors } from "celebrate"
|
||||
import secretKey from "@lib/middleware/secret-key"
|
||||
import markdown from "@lib/render-markdown"
|
||||
|
||||
export const app = express()
|
||||
|
||||
app.use(bodyParser.urlencoded({ extended: true }))
|
||||
app.use(bodyParser.json({ limit: "5mb" }))
|
||||
|
||||
const corsOptions = {
|
||||
origin: `http://localhost:3001`
|
||||
}
|
||||
app.use(cors(corsOptions))
|
||||
|
||||
app.use("/auth", auth)
|
||||
app.use("/posts", posts)
|
||||
app.use("/users", users)
|
||||
app.use("/files", files)
|
||||
|
||||
app.get('/welcome', secretKey, (req, res) => {
|
||||
const introContent = process.env.WELCOME_CONTENT;
|
||||
const introTitle = process.env.WELCOME_TITLE;
|
||||
|
||||
if (!introContent || !introTitle) {
|
||||
return res.status(500).json({ error: 'Missing welcome content' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
title: introTitle,
|
||||
content: introContent,
|
||||
rendered: markdown(introContent)
|
||||
});
|
||||
})
|
||||
|
||||
app.use(errors())
|
||||
|
||||
app.use(
|
||||
errorhandler({
|
||||
debug: process.env.ENV !== "production",
|
||||
log: true
|
||||
})
|
||||
errorhandler({
|
||||
debug: process.env.ENV !== "production",
|
||||
log: true
|
||||
})
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@ import * as crypto from "crypto"
|
|||
import { User } from "@lib/models/User"
|
||||
import secretKey from "@lib/middleware/secret-key"
|
||||
import markdown from "@lib/render-markdown"
|
||||
import { Op } from "sequelize"
|
||||
|
||||
export const posts = Router()
|
||||
|
||||
|
@ -98,36 +99,85 @@ posts.get("/", secretKey, async (req, res, next) => {
|
|||
}
|
||||
})
|
||||
|
||||
posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
include: [
|
||||
{
|
||||
model: Post,
|
||||
as: "posts",
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" })
|
||||
posts.get("/mine", jwt,
|
||||
celebrate({
|
||||
query: {
|
||||
page: Joi.number().integer().min(1).default(1).optional()
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const { page } = req.query
|
||||
const parsedPage = page ? parseInt(page as string) : 1
|
||||
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
include: [
|
||||
{
|
||||
model: Post,
|
||||
as: "posts",
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "createdAt"]
|
||||
},
|
||||
],
|
||||
attributes: ["id", "title", "visibility", "createdAt"]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" })
|
||||
}
|
||||
return res.json(
|
||||
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).slice((parsedPage - 1) * 10, parsedPage * 10)
|
||||
)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
posts.get("/search",
|
||||
jwt,
|
||||
celebrate({
|
||||
query: {
|
||||
q: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const { q } = req.query
|
||||
if (typeof q !== "string") {
|
||||
return res.status(400).json({ error: "Invalid query" })
|
||||
}
|
||||
|
||||
try {
|
||||
const posts = await Post.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ title: { [Op.like]: `%${q}%` } },
|
||||
{ "$files.title$": { [Op.like]: `%${q}%` } },
|
||||
{ "$files.content$": { [Op.like]: `%${q}%` } }
|
||||
]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title"]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
res.json(posts)
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
return res.json(
|
||||
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
posts.get(
|
||||
"/:id",
|
||||
|
@ -138,10 +188,7 @@ posts.get(
|
|||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const post = await Post.findOne({
|
||||
where: {
|
||||
id: req.params.id
|
||||
},
|
||||
const post = await Post.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
|
|
Loading…
Reference in a new issue