search filters
This commit is contained in:
parent
6225b9e7aa
commit
e4d150413b
|
@ -8,7 +8,8 @@ import invite from './invite'
|
|||
import sub from './sub'
|
||||
import upload from './upload'
|
||||
import growth from './growth'
|
||||
import search from './search'
|
||||
import { GraphQLJSONObject } from 'graphql-type-json'
|
||||
|
||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||
upload, growth, { JSONObject: GraphQLJSONObject }]
|
||||
upload, growth, search, { JSONObject: GraphQLJSONObject }]
|
||||
|
|
|
@ -435,123 +435,6 @@ export default {
|
|||
comments: async (parent, { id, sort }, { me, models }) => {
|
||||
return comments(me, models, id, sort)
|
||||
},
|
||||
search: async (parent, { q: query, sub, cursor }, { me, models, search }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let sitems
|
||||
|
||||
try {
|
||||
sitems = await search.search({
|
||||
index: 'item',
|
||||
size: LIMIT,
|
||||
from: decodedCursor.offset,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
sub
|
||||
? { match: { 'sub.name': sub } }
|
||||
: { bool: { must_not: { exists: { field: 'sub.name' } } } },
|
||||
me
|
||||
? {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } },
|
||||
{ match: { userId: me.id } }
|
||||
]
|
||||
}
|
||||
}
|
||||
: {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
// all terms are matched in fields
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^20', 'text'],
|
||||
minimum_should_match: '100%',
|
||||
boost: 400
|
||||
}
|
||||
},
|
||||
{
|
||||
// all terms are matched in fields
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^20', 'text'],
|
||||
fuzziness: 'AUTO',
|
||||
prefix_length: 3,
|
||||
minimum_should_match: '100%',
|
||||
boost: 20
|
||||
}
|
||||
},
|
||||
{
|
||||
// only some terms must match
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^20', 'text'],
|
||||
fuzziness: 'AUTO',
|
||||
prefix_length: 3,
|
||||
minimum_should_match: '60%'
|
||||
}
|
||||
}
|
||||
// TODO: add wildcard matches for
|
||||
// user.name and url
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
filter: {
|
||||
range: {
|
||||
createdAt: {
|
||||
lte: decodedCursor.time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
highlight: {
|
||||
fields: {
|
||||
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
|
||||
text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return {
|
||||
cursor: null,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
|
||||
// return highlights
|
||||
const items = sitems.body.hits.hits.map(async e => {
|
||||
// this is super inefficient but will suffice until we do something more generic
|
||||
const item = await getItem(parent, { id: e._source.id }, { me, models })
|
||||
|
||||
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
|
||||
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
|
||||
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
|
||||
let where
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { getItem } from './item'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let sitems
|
||||
|
||||
const sortArr = []
|
||||
switch (sort) {
|
||||
case 'recent':
|
||||
sortArr.push({ createdAt: 'desc' })
|
||||
break
|
||||
case 'comments':
|
||||
sortArr.push({ ncomments: 'desc' })
|
||||
break
|
||||
case 'sats':
|
||||
sortArr.push({ sats: 'desc' })
|
||||
break
|
||||
case 'votes':
|
||||
sortArr.push({ upvotes: 'desc' })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
sortArr.push('_score')
|
||||
|
||||
const whatArr = []
|
||||
switch (what) {
|
||||
case 'posts':
|
||||
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
|
||||
break
|
||||
case 'comments':
|
||||
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let whenGte
|
||||
switch (when) {
|
||||
case 'day':
|
||||
whenGte = 'now-1d'
|
||||
break
|
||||
case 'week':
|
||||
whenGte = 'now-7d'
|
||||
break
|
||||
case 'month':
|
||||
whenGte = 'now-30d'
|
||||
break
|
||||
case 'year':
|
||||
whenGte = 'now-365d'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
sitems = await search.search({
|
||||
index: 'item',
|
||||
size: LIMIT,
|
||||
from: decodedCursor.offset,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
...whatArr,
|
||||
sub
|
||||
? { match: { 'sub.name': sub } }
|
||||
: { bool: { must_not: { exists: { field: 'sub.name' } } } },
|
||||
me
|
||||
? {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } },
|
||||
{ match: { userId: me.id } }
|
||||
]
|
||||
}
|
||||
}
|
||||
: {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
// all terms are matched in fields
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^20', 'text'],
|
||||
minimum_should_match: '100%',
|
||||
boost: 400
|
||||
}
|
||||
},
|
||||
{
|
||||
// all terms are matched in fields
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^20', 'text'],
|
||||
fuzziness: 'AUTO',
|
||||
prefix_length: 3,
|
||||
minimum_should_match: '100%',
|
||||
boost: 20
|
||||
}
|
||||
},
|
||||
{
|
||||
// only some terms must match unless we're sorting
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'most_fields',
|
||||
fields: ['title^20', 'text'],
|
||||
fuzziness: 'AUTO',
|
||||
prefix_length: 3,
|
||||
minimum_should_match: sortArr.length > 1 ? '100%' : '60%'
|
||||
}
|
||||
}
|
||||
// TODO: add wildcard matches for
|
||||
// user.name and url
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
filter: {
|
||||
range: {
|
||||
createdAt: {
|
||||
lte: decodedCursor.time,
|
||||
gte: whenGte
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sort: sortArr,
|
||||
highlight: {
|
||||
fields: {
|
||||
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
|
||||
text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return {
|
||||
cursor: null,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
|
||||
// return highlights
|
||||
const items = sitems.body.hits.hits.map(async e => {
|
||||
// this is super inefficient but will suffice until we do something more generic
|
||||
const item = await getItem(parent, { id: e._source.id }, { me, models })
|
||||
|
||||
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
|
||||
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ export default gql`
|
|||
pageTitle(url: String!): String
|
||||
dupes(url: String!): [Item!]
|
||||
allItems(cursor: String): Items
|
||||
search(q: String, sub: String, cursor: String): Items
|
||||
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
|
||||
auctionPosition(sub: String, id: ID, bid: Int!): Int!
|
||||
itemRepetition(parentId: ID): Int!
|
||||
outlawedItems(cursor: String): Items
|
||||
|
|
|
@ -193,11 +193,14 @@ function InputInner ({
|
|||
{(clear && field.value) &&
|
||||
<Button
|
||||
variant={null}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
helpers.setValue('')
|
||||
if (storageKey) {
|
||||
localStorage.removeItem(storageKey)
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(formik, { target: { value: '' } })
|
||||
}
|
||||
}}
|
||||
className={`${styles.clearButton} ${invalid ? styles.isInvalid : ''}`}
|
||||
><CloseIcon className='fill-grey' height={20} width={20} />
|
||||
|
@ -430,12 +433,24 @@ export function SyncForm ({
|
|||
)
|
||||
}
|
||||
|
||||
export function Select ({ label, items, groupClassName, ...props }) {
|
||||
export function Select ({ label, items, groupClassName, onChange, noForm, ...props }) {
|
||||
const [field] = useField(props)
|
||||
const formik = noForm ? null : useFormikContext()
|
||||
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<BootstrapForm.Control as='select' custom {...field} {...props}>
|
||||
<BootstrapForm.Control
|
||||
as='select'
|
||||
{...field} {...props}
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
|
||||
if (onChange) {
|
||||
onChange(formik, e)
|
||||
}
|
||||
}}
|
||||
custom
|
||||
>
|
||||
{items.map(item => <option key={item}>{item}</option>)}
|
||||
</BootstrapForm.Control>
|
||||
</FormGroup>
|
||||
|
|
|
@ -3,13 +3,13 @@ import styles from './search.module.css'
|
|||
import SearchIcon from '../svgs/search-line.svg'
|
||||
import CloseIcon from '../svgs/close-line.svg'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form, Input, SubmitButton } from './form'
|
||||
import { Form, Input, Select, SubmitButton } from './form'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export default function Search ({ sub }) {
|
||||
const router = useRouter()
|
||||
const [searching, setSearching] = useState(router.query.q)
|
||||
const [q, setQ] = useState(router.query.q)
|
||||
const [q, setQ] = useState(router.query.q || '')
|
||||
const [atBottom, setAtBottom] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -23,28 +23,67 @@ export default function Search ({ sub }) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const showSearch = atBottom || searching || router.query.q
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
|
||||
<Container className={`px-sm-0 ${styles.searchContainer}`}>
|
||||
{showSearch
|
||||
? (
|
||||
<Form
|
||||
initial={{
|
||||
q: router.query.q || ''
|
||||
}}
|
||||
className={`w-auto ${styles.active}`}
|
||||
onSubmit={async ({ q }) => {
|
||||
if (q.trim() !== '') {
|
||||
const search = async values => {
|
||||
let prefix = ''
|
||||
if (sub) {
|
||||
prefix = `/~${sub}`
|
||||
}
|
||||
router.push(prefix + `/search?q=${encodeURIComponent(q)}`)
|
||||
if (values.q?.trim() !== '') {
|
||||
if (values.what === '') delete values.what
|
||||
if (values.sort === '') delete values.sort
|
||||
if (values.when === '') delete values.when
|
||||
await router.push({
|
||||
pathname: prefix + '/search',
|
||||
query: values
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const showSearch = atBottom || searching || router.query.q
|
||||
const filter = router.query.q && !sub
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
|
||||
<Container className={`px-sm-0 ${styles.searchContainer} ${filter ? styles.leaveRoom : ''}`}>
|
||||
{showSearch
|
||||
? (
|
||||
<Form
|
||||
className={styles.formActive}
|
||||
initial={{
|
||||
q: router.query.q || '',
|
||||
what: router.query.what || '',
|
||||
sort: router.query.sort || '',
|
||||
when: router.query.when || ''
|
||||
}}
|
||||
onSubmit={search}
|
||||
>
|
||||
{filter &&
|
||||
<div className='text-muted font-weight-bold my-3 d-flex align-items-center'>
|
||||
<Select
|
||||
groupClassName='mr-2 mb-0'
|
||||
onChange={(formik, e) => search({ ...formik?.values, what: e.target.value })}
|
||||
name='what'
|
||||
size='sm'
|
||||
items={['all', 'posts', 'comments']}
|
||||
/>
|
||||
by
|
||||
<Select
|
||||
groupClassName='mx-2 mb-0'
|
||||
onChange={(formik, e) => search({ ...formik?.values, sort: e.target.value })}
|
||||
name='sort'
|
||||
size='sm'
|
||||
items={['match', 'recent', 'comments', 'sats', 'votes']}
|
||||
/>
|
||||
for
|
||||
<Select
|
||||
groupClassName='mb-0 ml-2'
|
||||
onChange={(formik, e) => search({ ...formik?.values, when: e.target.value })}
|
||||
name='when'
|
||||
size='sm'
|
||||
items={['forever', 'day', 'week', 'month', 'year']}
|
||||
/>
|
||||
</div>}
|
||||
<div className={`${styles.active}`}>
|
||||
<Input
|
||||
name='q'
|
||||
required
|
||||
|
@ -71,17 +110,18 @@ export default function Search ({ sub }) {
|
|||
>
|
||||
<CloseIcon width={26} height={26} />
|
||||
</Button>)}
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
)
|
||||
: (
|
||||
<Button className={`${styles.search} ${styles.active}`} onClick={() => setSearching(true)}>
|
||||
<Button className={`${styles.search} ${styles.formActive}`} onClick={() => setSearching(true)}>
|
||||
<SearchIcon width={22} height={22} />
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
<div className={styles.searchPadding} />
|
||||
<div className={`${styles.searchPadding} ${filter ? styles.leaveRoom : ''}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
|
@ -11,6 +10,10 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.leaveRoom {
|
||||
height: 130px !important;
|
||||
}
|
||||
|
||||
.searchSection.solid {
|
||||
pointer-events: auto;
|
||||
background: var(--theme-body);
|
||||
|
@ -30,18 +33,20 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
left: auto !important;
|
||||
}
|
||||
|
||||
.active {
|
||||
.formActive {
|
||||
pointer-events: auto;
|
||||
bottom: 18px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
form.active {
|
||||
left: 18px;
|
||||
form>.active {
|
||||
display: flex;
|
||||
pointer-events: auto;
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
@ -175,8 +175,8 @@ export const ITEM_WITH_COMMENTS = gql`
|
|||
|
||||
export const ITEM_SEARCH = gql`
|
||||
${ITEM_FIELDS}
|
||||
query Search($q: String, $cursor: String) {
|
||||
search(q: $q, cursor: $cursor) {
|
||||
query Search($q: String, $cursor: String, $sort: String, $what: String, $when: String) {
|
||||
search(q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
|
|
|
@ -92,7 +92,7 @@ export default function getApolloClient () {
|
|||
}
|
||||
},
|
||||
search: {
|
||||
keyArgs: ['q'],
|
||||
keyArgs: ['q', 'sub', 'sort', 'what', 'when'],
|
||||
merge (existing, incoming) {
|
||||
if (isFirstPage(incoming.cursor, existing?.items)) {
|
||||
return incoming
|
||||
|
|
|
@ -15,7 +15,8 @@ export default function Index ({ data: { search: { items, cursor } } }) {
|
|||
<SeoSearch />
|
||||
{router.query?.q &&
|
||||
<SearchItems
|
||||
items={items} cursor={cursor} variables={{ q: router.query?.q }}
|
||||
items={items} cursor={cursor}
|
||||
variables={{ q: router.query?.q, sort: router.query?.sort, what: router.query?.what, when: router.query?.when }}
|
||||
/>}
|
||||
</Layout>
|
||||
)
|
||||
|
|
|
@ -282,7 +282,6 @@ footer {
|
|||
@media screen and (max-width: 767px) {
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.form-control,
|
||||
.form-control:focus,
|
||||
|
|
Loading…
Reference in New Issue