client: add client-side search of posts list

This commit is contained in:
Max Leiter 2022-03-24 15:35:59 -07:00
parent e5f467b26a
commit b77265e6b6
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
4 changed files with 105 additions and 32 deletions

View file

@ -1,33 +1,94 @@
import { Text } from "@geist-ui/core" import { Code, Dot, Input, Note, Text } from "@geist-ui/core"
import NextLink from "next/link" import NextLink from "next/link"
import Link from '../Link' import Link from '../Link'
import styles from './post-list.module.css' 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 { ChangeEvent, useEffect, useMemo, useState } from "react"
import debounce from "lodash.debounce"
type Props = { type Props = {
posts: any posts: Post[]
error: any error: any
} }
const PostList = ({ posts, error }: Props) => { const PostList = ({ posts, error }: Props) => {
const [search, setSearchValue] = useState('')
// const [searching, setSearching] = useState(false)
const [searchResults, setSearchResults] = useState<Post[]>(posts)
// 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
}
}
return post.title.toLowerCase().includes(search.toLowerCase())
})
setSearchResults(results)
} else {
setSearchResults(posts)
}
}, [search, posts])
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
}
const debouncedSearchHandler = useMemo(
() => debounce(handleSearchChange, 300)
, []);
useEffect(() => {
return () => {
debouncedSearchHandler.cancel();
}
}, [debouncedSearchHandler]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.searchContainer}>
<Input scale={3 / 2}
clearable
placeholder="is:private"
onChange={debouncedSearchHandler} />
<Text type="secondary">Available filters: <Code>is:visibility</Code></Text>
</div>
{error && <Text type='error'>Failed to load.</Text>} {error && <Text type='error'>Failed to load.</Text>}
{!posts && <ul>
<li>
<ListItemSkeleton />
</li>
<li>
<ListItemSkeleton />
</li>
</ul>}
{posts?.length === 0 && <Text>You have no posts. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
{ {
posts?.length > 0 && <div> !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>
<ul> <ul>
{posts.map((post: any) => { {searchResults.map((post) => {
return <ListItem post={post} key={post.id} /> return <ListItem post={post} key={post.id} />
})} })}
</ul> </ul>

View file

@ -8,13 +8,14 @@ import getPostPath from "@lib/get-post-path"
import { Input, Link, Text, Card, Spacer, Grid, Tooltip, Divider } from "@geist-ui/core" import { Input, Link, Text, Card, Spacer, Grid, Tooltip, Divider } from "@geist-ui/core"
const FilenameInput = ({ title }: { title: string }) => <Input const FilenameInput = ({ title }: { title: string }) => <Input
value={title} value={title || 'No title'}
marginTop="var(--gap)" marginTop="var(--gap)"
size={1.2} size={1.2}
font={1.2} font={1.2}
label="Filename" label="Filename"
readOnly readOnly
width={"100%"} width={"100%"}
style={{ color: !!title ? 'var(--fg)' : 'var(--gray)' }}
/> />
const ListItem = ({ post }: { post: any }) => { const ListItem = ({ post }: { post: any }) => {

View file

@ -1,26 +1,35 @@
.container ul { .container ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.container ul li { .container ul li {
padding: 0.5rem 0; padding: 0.5rem 0;
} }
.container ul li::before { .container ul li::before {
content: ""; content: "";
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.postHeader { .postHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: var(--gap); padding: var(--gap);
align-items: center; align-items: center;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
background: inherit; background: inherit;
}
.searchContainer {
display: flex;
align-items: center;
flex-direction: column-reverse;
justify-content: center;
margin-top: var(--gap);
margin-bottom: var(--gap-double);
} }

View file

@ -8,15 +8,17 @@
--gap-half: 0.5rem; --gap-half: 0.5rem;
--gap: 1rem; --gap: 1rem;
--gap-double: 2rem; --gap-double: 2rem;
--gap-negative: calc(-1 * var(--gap));
--gap-half-negative: calc(-1 * var(--gap-half));
--gap-quarter-negative: calc(-1 * var(--gap-quarter));
--small-gap: 4rem; --small-gap: 4rem;
--big-gap: 4rem; --big-gap: 4rem;
--main-content: 55rem; --main-content: 55rem;
--radius: 8px; --radius: 8px;
--inline-radius: 5px; --inline-radius: 5px;
--gap-negative: calc(-1 * var(--gap));
--gap-half-negative: calc(-1 * var(--gap-half));
--gap-quarter-negative: calc(-1 * var(--gap-quarter));
/* Typography */ /* Typography */
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, --font-sans: "Inter", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;