client/server: add post searching

This commit is contained in:
Max Leiter 2022-03-24 18:03:57 -07:00
parent 9949faeebd
commit 951088bacf
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
16 changed files with 266 additions and 142 deletions

View file

@ -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

View file

@ -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

View file

@ -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 >
</>

View file

@ -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}

View file

@ -0,0 +1,3 @@
.textarea {
height: 100%;
}

View 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

View file

@ -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

View file

@ -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>
)
}

View file

@ -30,6 +30,5 @@
align-items: center;
flex-direction: column-reverse;
justify-content: center;
margin-top: var(--gap);
margin-bottom: var(--gap-double);
}

View file

@ -38,3 +38,7 @@
position: absolute;
right: 0;
}
.textarea {
height: 100%;
}

View file

@ -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 >
</>

View file

@ -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>
)

View file

@ -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,
}
}
}

View file

@ -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>

View file

@ -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
})
)

View file

@ -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,