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`:
|
`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
|
- `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
|
- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
|
||||||
|
|
||||||
`server/.env`:
|
`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.
|
- `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.
|
- `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
|
- `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
|
## Current status
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,2 @@
|
||||||
API_URL=http://localhost:3000
|
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
|
SECRET_KEY=secret
|
||||||
|
|
|
@ -19,12 +19,11 @@ type Props = {
|
||||||
setContent?: (content: string) => void
|
setContent?: (content: string) => void
|
||||||
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
initialTab?: "edit" | "preview"
|
initialTab?: "edit" | "preview"
|
||||||
skeleton?: boolean
|
|
||||||
remove?: () => void
|
remove?: () => void
|
||||||
onPaste?: (e: any) => 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 codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const [tab, setTab] = useState(initialTab)
|
const [tab, setTab] = useState(initialTab)
|
||||||
// const height = editable ? "500px" : '100%'
|
// const height = editable ? "500px" : '100%'
|
||||||
|
@ -52,21 +51,21 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
|
||||||
}
|
}
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
if (skeleton) {
|
// if (skeleton) {
|
||||||
return <>
|
// return <>
|
||||||
<Spacer height={1} />
|
// <Spacer height={1} />
|
||||||
<div className={styles.card}>
|
// <div className={styles.card}>
|
||||||
<div className={styles.fileNameContainer}>
|
// <div className={styles.fileNameContainer}>
|
||||||
<Skeleton width={275} height={36} />
|
// <Skeleton width={275} height={36} />
|
||||||
{remove && <Skeleton width={36} height={36} />}
|
// {remove && <Skeleton width={36} height={36} />}
|
||||||
</div>
|
// </div>
|
||||||
<div className={styles.descriptionContainer}>
|
// <div className={styles.descriptionContainer}>
|
||||||
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
||||||
<Skeleton width={'100%'} height={350} />
|
// <Skeleton width={'100%'} height={350} />
|
||||||
</div >
|
// </div >
|
||||||
</div>
|
// </div>
|
||||||
</>
|
// </>
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -112,7 +111,6 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Item>
|
</Tabs.Item>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
</div >
|
</div >
|
||||||
</div >
|
</div >
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -129,7 +129,7 @@ const Header = () => {
|
||||||
|
|
||||||
|
|
||||||
return (
|
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}>
|
<div className={styles.tabs}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={selectedTab}
|
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"
|
import PostList from "../post-list"
|
||||||
|
|
||||||
const MyPosts = ({ posts, error }: { posts: any, error: any }) => {
|
const MyPosts = ({ posts, error, morePosts }:
|
||||||
return <PostList posts={posts} error={error} />
|
{
|
||||||
|
posts: Post[],
|
||||||
|
error: boolean,
|
||||||
|
morePosts: boolean
|
||||||
|
}) => {
|
||||||
|
return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyPosts
|
export default MyPosts
|
||||||
|
|
|
@ -6,48 +6,59 @@ import styles from './post-list.module.css'
|
||||||
import ListItemSkeleton from "./list-item-skeleton"
|
import ListItemSkeleton from "./list-item-skeleton"
|
||||||
import ListItem from "./list-item"
|
import ListItem from "./list-item"
|
||||||
import { Post } from "@lib/types"
|
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 debounce from "lodash.debounce"
|
||||||
|
import Cookies from "js-cookie"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
posts: Post[]
|
initialPosts: Post[]
|
||||||
error: any
|
error: boolean
|
||||||
|
morePosts: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostList = ({ posts, error }: Props) => {
|
const PostList = ({ morePosts, initialPosts, error }: Props) => {
|
||||||
const [search, setSearchValue] = useState('')
|
const [search, setSearchValue] = useState('')
|
||||||
// const [searching, setSearching] = useState(false)
|
const [posts, setPosts] = useState<Post[]>(initialPosts)
|
||||||
const [searchResults, setSearchResults] = useState<Post[]>(posts)
|
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
|
// update posts on search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (search) {
|
if (search) {
|
||||||
// support filters like "title is:private has:content" in the text
|
// fetch results from /server-api/posts/search
|
||||||
// extract the filters
|
const fetchResults = async () => {
|
||||||
const filters = search.split(' ').filter(s => s.includes(':'))
|
setSearching(true)
|
||||||
const filtersMap = new Map<string, string>()
|
//encode search
|
||||||
filters.forEach(f => {
|
const res = await fetch(`/server-api/posts/search?q=${encodeURIComponent(search)}`, {
|
||||||
const [key, value] = f.split(':')
|
method: "GET",
|
||||||
filtersMap.set(key, value)
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
||||||
|
// "tok": process.env.SECRET_KEY || ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
const data = await res.json()
|
||||||
const results = posts.filter(post => {
|
setPosts(data)
|
||||||
if (filtersMap.has('is') && filtersMap.get('is') !== post.visibility) {
|
setSearching(false)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
for (const file of post.files) {
|
fetchResults()
|
||||||
if (file.content.toLowerCase().includes(search.toLowerCase())) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return post.title.toLowerCase().includes(search.toLowerCase())
|
|
||||||
})
|
|
||||||
setSearchResults(results)
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
setSearchResults(posts)
|
setPosts(initialPosts)
|
||||||
}
|
}
|
||||||
}, [search, posts])
|
}, [initialPosts, search])
|
||||||
|
|
||||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchValue(e.target.value)
|
setSearchValue(e.target.value)
|
||||||
|
@ -68,33 +79,36 @@ const PostList = ({ posts, error }: Props) => {
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
<Input scale={3 / 2}
|
<Input scale={3 / 2}
|
||||||
clearable
|
clearable
|
||||||
placeholder="is:private"
|
placeholder="Search..."
|
||||||
onChange={debouncedSearchHandler} />
|
onChange={debouncedSearchHandler} />
|
||||||
<Text type="secondary">Available filters: <Code>is:visibility</Code></Text>
|
<Text type="secondary">Available filters: <Code>is:visibility</Code></Text>
|
||||||
</div>
|
</div>
|
||||||
{error && <Text type='error'>Failed to load.</Text>}
|
{error && <Text type='error'>Failed to load.</Text>}
|
||||||
{
|
{!posts && searching && <ul>
|
||||||
!posts && <ul>
|
|
||||||
<li>
|
<li>
|
||||||
<ListItemSkeleton />
|
<ListItemSkeleton />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<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 && <Text>No posts returned. 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>
|
<ul>
|
||||||
{searchResults.map((post) => {
|
{posts.map((post) => {
|
||||||
return <ListItem post={post} key={post.id} />
|
return <ListItem post={post} key={post.id} />
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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;
|
align-items: center;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: var(--gap);
|
|
||||||
margin-bottom: var(--gap-double);
|
margin-bottom: var(--gap-double);
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,3 +38,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
|
@ -117,12 +117,10 @@ const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props)
|
||||||
</Tabs.Item>
|
</Tabs.Item>
|
||||||
<Tabs.Item label="Preview" value="preview">
|
<Tabs.Item label="Preview" value="preview">
|
||||||
<div style={{ marginTop: 'var(--gap-half)', }}>
|
<div style={{ marginTop: 'var(--gap-half)', }}>
|
||||||
|
<HtmlPreview height={height} fileId={id} content={content} title={title} />
|
||||||
<HtmlPreview height={height} fileId={id} />
|
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Item>
|
</Tabs.Item>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
</div >
|
</div >
|
||||||
</div >
|
</div >
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,17 +1,25 @@
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from '@styles/Home.module.css'
|
||||||
import Header from '@components/header'
|
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 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() {
|
export async function getStaticProps() {
|
||||||
const introDoc = process.env.WELCOME_CONTENT
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
introContent: introDoc,
|
introContent: content || null,
|
||||||
introTitle: process.env.WELCOME_TITLE,
|
rendered: rendered || null,
|
||||||
|
introTitle: title || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,26 +27,18 @@ export function getStaticProps() {
|
||||||
type Props = {
|
type Props = {
|
||||||
introContent: string
|
introContent: string
|
||||||
introTitle: string
|
introTitle: string
|
||||||
|
rendered: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ introContent, introTitle }: Props) => {
|
const Home = ({ rendered, introContent, introTitle }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Page className={styles.container}>
|
<Page className={styles.wrapper}>
|
||||||
<PageSeo />
|
<PageSeo />
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Header />
|
<Header />
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
<HomeComponent rendered={rendered} introContent={introContent} introTitle={introTitle} />
|
||||||
<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`}
|
|
||||||
/>
|
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|
|
@ -7,14 +7,14 @@ import type { GetServerSideProps } from 'next';
|
||||||
import { Post } from '@lib/types';
|
import { Post } from '@lib/types';
|
||||||
import { Page } from '@geist-ui/core';
|
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 (
|
return (
|
||||||
<Page className={styles.container}>
|
<Page className={styles.wrapper}>
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Header />
|
<Header />
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<MyPosts error={error} posts={posts} />
|
<MyPosts morePosts={morePosts} error={error} posts={posts} />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page >
|
</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",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
@ -49,10 +49,13 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await posts.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
posts: await posts.json(),
|
posts: data,
|
||||||
error: posts.status !== 200,
|
error: posts.status !== 200,
|
||||||
|
morePosts: data.length > 10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Page } from '@geist-ui/core'
|
||||||
|
|
||||||
const New = () => {
|
const New = () => {
|
||||||
return (
|
return (
|
||||||
<Page className={styles.container} width="100%">
|
<Page className={styles.wrapper}>
|
||||||
<PageSeo title="Drift - New" />
|
<PageSeo title="Drift - New" />
|
||||||
|
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
|
|
|
@ -1,25 +1,36 @@
|
||||||
import * as express from "express"
|
import * as express from "express"
|
||||||
import * as bodyParser from "body-parser"
|
import * as bodyParser from "body-parser"
|
||||||
import * as errorhandler from "strong-error-handler"
|
import * as errorhandler from "strong-error-handler"
|
||||||
import * as cors from "cors"
|
|
||||||
import { posts, users, auth, files } from "@routes/index"
|
import { posts, users, auth, files } from "@routes/index"
|
||||||
import { errors } from "celebrate"
|
import { errors } from "celebrate"
|
||||||
|
import secretKey from "@lib/middleware/secret-key"
|
||||||
|
import markdown from "@lib/render-markdown"
|
||||||
|
|
||||||
export const app = express()
|
export const app = express()
|
||||||
|
|
||||||
app.use(bodyParser.urlencoded({ extended: true }))
|
app.use(bodyParser.urlencoded({ extended: true }))
|
||||||
app.use(bodyParser.json({ limit: "5mb" }))
|
app.use(bodyParser.json({ limit: "5mb" }))
|
||||||
|
|
||||||
const corsOptions = {
|
|
||||||
origin: `http://localhost:3001`
|
|
||||||
}
|
|
||||||
app.use(cors(corsOptions))
|
|
||||||
|
|
||||||
app.use("/auth", auth)
|
app.use("/auth", auth)
|
||||||
app.use("/posts", posts)
|
app.use("/posts", posts)
|
||||||
app.use("/users", users)
|
app.use("/users", users)
|
||||||
app.use("/files", files)
|
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(errors())
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|
|
@ -7,6 +7,7 @@ import * as crypto from "crypto"
|
||||||
import { User } from "@lib/models/User"
|
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"
|
||||||
|
|
||||||
export const posts = Router()
|
export const posts = Router()
|
||||||
|
|
||||||
|
@ -98,11 +99,20 @@ posts.get("/", secretKey, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => {
|
posts.get("/mine", jwt,
|
||||||
|
celebrate({
|
||||||
|
query: {
|
||||||
|
page: Joi.number().integer().min(1).default(1).optional()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (req: UserJwtRequest, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized" })
|
return res.status(401).json({ error: "Unauthorized" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { page } = req.query
|
||||||
|
const parsedPage = page ? parseInt(page as string) : 1
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.findByPk(req.user.id, {
|
const user = await User.findByPk(req.user.id, {
|
||||||
include: [
|
include: [
|
||||||
|
@ -112,9 +122,11 @@ posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: File,
|
model: File,
|
||||||
as: "files"
|
as: "files",
|
||||||
}
|
attributes: ["id", "title", "createdAt"]
|
||||||
]
|
},
|
||||||
|
],
|
||||||
|
attributes: ["id", "title", "visibility", "createdAt"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -122,12 +134,50 @@ posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => {
|
||||||
return res.status(404).json({ error: "User not found" })
|
return res.status(404).json({ error: "User not found" })
|
||||||
}
|
}
|
||||||
return res.json(
|
return res.json(
|
||||||
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).slice((parsedPage - 1) * 10, parsedPage * 10)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
posts.get(
|
posts.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
@ -138,10 +188,7 @@ posts.get(
|
||||||
}),
|
}),
|
||||||
async (req: UserJwtRequest, res, next) => {
|
async (req: UserJwtRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const post = await Post.findOne({
|
const post = await Post.findByPk(req.params.id, {
|
||||||
where: {
|
|
||||||
id: req.params.id
|
|
||||||
},
|
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: File,
|
model: File,
|
||||||
|
|
Loading…
Reference in a new issue