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`: `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

View file

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

View file

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

View file

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

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

View file

@ -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")}`,
const results = posts.filter(post => { // "tok": process.env.SECRET_KEY || ''
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
} }
} })
return post.title.toLowerCase().includes(search.toLowerCase()) const data = await res.json()
}) setPosts(data)
setSearchResults(results) setSearching(false)
}
fetchResults()
} 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>
<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> posts?.length > 0 && <div>
<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>
<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>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,41 @@
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(
errorhandler({ errorhandler({
debug: process.env.ENV !== "production", debug: process.env.ENV !== "production",
log: true log: true
}) })
) )

View file

@ -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,36 +99,85 @@ posts.get("/", secretKey, async (req, res, next) => {
} }
}) })
posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => { posts.get("/mine", jwt,
if (!req.user) { celebrate({
return res.status(401).json({ error: "Unauthorized" }) query: {
} page: Joi.number().integer().min(1).default(1).optional()
}
try { }),
const user = await User.findByPk(req.user.id, { async (req: UserJwtRequest, res, next) => {
include: [ if (!req.user) {
{ return res.status(401).json({ error: "Unauthorized" })
model: Post, }
as: "posts",
include: [ const { page } = req.query
{ const parsedPage = page ? parseInt(page as string) : 1
model: File,
as: "files" try {
} const user = await User.findByPk(req.user.id, {
] include: [
} {
] model: Post,
}) as: "posts",
if (!user) { include: [
return res.status(404).json({ error: "User not found" }) {
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( 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,