jobs w/o payments yet

This commit is contained in:
keyan 2022-02-17 11:23:43 -06:00
parent 155307127c
commit b954186d31
45 changed files with 974 additions and 13360 deletions

View File

@ -5,5 +5,6 @@ import wallet from './wallet'
import lnurl from './lnurl'
import notifications from './notifications'
import invite from './invite'
import sub from './sub'
export default [user, item, message, wallet, lnurl, notifications, invite]
export default [user, item, message, wallet, lnurl, notifications, invite, sub]

View File

@ -73,9 +73,13 @@ function topClause (within) {
export default {
Query: {
moreItems: async (parent, { sort, cursor, name, within }, { me, models }) => {
items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items; let user; let pins
let items; let user; let pins; let subFull
const subClause = (num) => {
return sub ? ` AND "subName" = $${num} ` : `AND ("subName" IS NULL OR "subName" = $${3}) `
}
switch (sort) {
case 'user':
@ -97,73 +101,96 @@ export default {
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
break
case 'hot':
// HACK we can speed hack the first hot page, by limiting our query to only
// the most recently created items so that the tables doesn't have to
// fully be computed
// if the offset is 0, we limit our search to posts from the last week
// if there are 21 items, return them ... if not do the unrestricted query
// instead of doing this we should materialize a view ... but this is easier for now
if (decodedCursor.offset === 0) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinWeightedSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1 AND created_at > $3
AND "pinId" IS NULL
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date() - 7))
}
if (decodedCursor.offset !== 0 || items?.length < LIMIT) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinWeightedSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
}
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await models.$queryRaw(`SELECT rank_filter.*
FROM (
${SELECT},
rank() OVER (
PARTITION BY "pinId"
ORDER BY created_at DESC
)
FROM "Item"
WHERE "pinId" IS NOT NULL
) rank_filter WHERE RANK = 1`)
}
break
case 'top':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinWeightedSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${topClause(within)}
ORDER BY x.sats DESC NULLS LAST, created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
default:
case 'recent':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL
${subClause(3)}
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
break
case 'top':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinWeightedSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${topClause(within)}
ORDER BY x.sats DESC NULLS LAST, created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
default:
// sub so we know the default ranking
if (sub) {
subFull = await models.sub.findUnique({ where: { name: sub } })
}
switch (subFull?.rankingType) {
case 'AUCTION':
// it might be sufficient to sort by the floor(maxBid / 1000) desc, created_at desc
// we pull from their wallet
// TODO: need to filter out by payment status
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
ORDER BY "maxBid" / 1000 DESC, created_at ASC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
break
default:
// HACK we can speed hack the first hot page, by limiting our query to only
// the most recently created items so that the tables doesn't have to
// fully be computed
// if the offset is 0, we limit our search to posts from the last week
// if there are 21 items, return them ... if not do the unrestricted query
// instead of doing this we should materialize a view ... but this is easier for now
if (decodedCursor.offset === 0) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinWeightedSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1 AND created_at > $3
AND "pinId" IS NULL
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date() - 7))
}
if (decodedCursor.offset !== 0 || items?.length < LIMIT) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinWeightedSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
}
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await models.$queryRaw(`SELECT rank_filter.*
FROM (
${SELECT},
rank() OVER (
PARTITION BY "pinId"
ORDER BY created_at DESC
)
FROM "Item"
WHERE "pinId" IS NOT NULL
) rank_filter WHERE RANK = 1`)
}
break
}
break
}
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
@ -264,7 +291,7 @@ export default {
comments: async (parent, { id, sort }, { models }) => {
return comments(models, id, sort)
},
search: async (parent, { q: query, cursor }, { models, search }) => {
search: async (parent, { q: query, sub, cursor }, { models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems
@ -276,47 +303,52 @@ export default {
body: {
query: {
bool: {
must: {
bool: {
should: [
{
must: [
sub
? { term: { 'sub.name': sub } }
: { bool: { must_not: { exists: { field: 'sub.name' } } } },
{
bool: {
should: [
{
// all terms are matched in fields
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
minimum_should_match: '100%',
boost: 400
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%'
}
}
},
{
// 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
]
// TODO: add wildcard matches for
// user.name and url
]
}
}
},
],
filter: {
range: {
createdAt: {
@ -356,6 +388,36 @@ export default {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
auctionPosition: async (parent, { id, sub, bid }, { models }) => {
// count items that have a bid gte to the current bid + 1000 or
// gte current bid and older
const where = {
where: {
subName: sub,
OR: [{
maxBid: {
gte: bid + 1000
}
}, {
AND: [{
maxBid: {
gte: bid
}
}, {
createdAt: {
lt: new Date()
}
}]
}]
}
}
if (id) {
where.where.id = { not: Number(id) }
}
return await models.item.count(where) + 1
}
},
@ -425,6 +487,51 @@ export default {
return await updateItem(parent, { id, data: { title, text } }, { me, models })
},
upsertJob: async (parent, { id, sub, title, text, url, maxBid }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in to create job')
}
if (!sub) {
throw new UserInputError('jobs must have a sub', { argumentName: 'sub' })
}
const fullSub = await models.sub.findUnique({ where: { name: sub } })
if (!fullSub) {
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
}
const params = { title, text, url }
for (const param in params) {
if (!params[param]) {
throw new UserInputError(`jobs must have ${param}`, { argumentName: param })
}
}
if (fullSub.baseCost > maxBid) {
throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' })
}
const data = {
title,
text,
url,
maxBid,
subName: sub,
userId: me.id
}
if (id) {
return await models.item.update({
where: { id: Number(id) },
data
})
}
return await models.item.create({
data
})
},
createComment: async (parent, { text, parentId }, { me, models }) => {
if (!text) {
throw new UserInputError('comment must have text', { argumentName: 'text' })
@ -486,6 +593,13 @@ export default {
},
Item: {
sub: async (item, args, { models }) => {
if (!item.subName) {
return null
}
return await models.sub.findUnique({ where: { name: item.subName } })
},
position: async (item, args, { models }) => {
if (!item.pinId) {
return null
@ -735,7 +849,8 @@ function nestComments (flat, parentId) {
// we have to do our own query because ltree is unsupported
export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", "Item"."pinId", ltree2text("Item"."path") AS "path"`
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item"."subName", ltree2text("Item"."path") AS "path"`
const LEFT_JOIN_SATS_SELECT = 'SELECT i.id, SUM(CASE WHEN "ItemAct".act = \'VOTE\' THEN "ItemAct".sats ELSE 0 END) as sats, SUM(CASE WHEN "ItemAct".act = \'BOOST\' THEN "ItemAct".sats ELSE 0 END) as boost'

11
api/resolvers/sub.js Normal file
View File

@ -0,0 +1,11 @@
export default {
Query: {
sub: async (parent, { name }, { models }) => {
return await models.sub.findUnique({
where: {
name
}
})
}
}
}

View File

@ -7,6 +7,7 @@ import wallet from './wallet'
import lnurl from './lnurl'
import notifications from './notifications'
import invite from './invite'
import sub from './sub'
const link = gql`
type Query {
@ -22,4 +23,4 @@ const link = gql`
}
`
export default [link, user, item, message, wallet, lnurl, notifications, invite]
export default [link, user, item, message, wallet, lnurl, notifications, invite, sub]

View File

@ -2,14 +2,15 @@ import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
moreItems(sort: String!, cursor: String, name: String, within: String): Items
items(sub: String, sort: String, cursor: String, name: String, within: String): Items
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
item(id: ID!): Item
comments(id: ID!, sort: String): [Item!]!
pageTitle(url: String!): String
dupes(url: String!): [Item!]
allItems(cursor: String): Items
search(q: String, cursor: String): Items
search(q: String, sub: String, cursor: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
}
type ItemActResult {
@ -24,6 +25,7 @@ export default gql`
updateDiscussion(id: ID!, title: String!, text: String): Item!
createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): Item!
upsertJob(id: ID, sub: ID!, title: String!, text: String!, url: String!, maxBid: Int!): Item!
act(id: ID!, sats: Int): ItemActResult!
}
@ -63,5 +65,7 @@ export default gql`
path: String
position: Int
prior: Int
maxBid: Int
sub: Sub
}
`

16
api/typeDefs/sub.js Normal file
View File

@ -0,0 +1,16 @@
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
sub(name: ID!): Sub
}
type Sub {
name: ID!
createdAt: String!
updatedAt: String!
postTypes: [String!]!
rankingType: String!
baseCost: Int!
}
`

View File

@ -11,7 +11,9 @@ import Markdown from '../svgs/markdown-line.svg'
import styles from './form.module.css'
import Text from '../components/text'
export function SubmitButton ({ children, variant, value, onClick, ...props }) {
export function SubmitButton ({
children, variant, value, onClick, ...props
}) {
const { isSubmitting, setFieldValue } = useFormikContext()
return (
<Button
@ -249,6 +251,7 @@ export function Form ({
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
{storageKeyPrefix
? React.Children.map(children, (child) => {
// if child has a type it's a dom element
if (child) {
return React.cloneElement(child, {
storageKeyPrefix

View File

@ -11,14 +11,7 @@ import { signOut, signIn } from 'next-auth/client'
import { useLightning } from './lightning'
import { useEffect, useState } from 'react'
import { randInRange } from '../lib/rand'
const formatSats = n => {
if (n < 1e4) return n
if (n >= 1e4 && n < 1e6) return +(n / 1e3).toFixed(1) + 'k'
if (n >= 1e6 && n < 1e9) return +(n / 1e6).toFixed(1) + 'm'
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b'
if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't'
}
import { formatSats } from '../lib/format'
function WalletSummary ({ me }) {
if (!me) return null
@ -26,11 +19,12 @@ function WalletSummary ({ me }) {
return `${formatSats(me.sats)} \\ ${formatSats(me.stacked)}`
}
export default function Header () {
export default function Header ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
const [fired, setFired] = useState()
const me = useMe()
const prefix = sub ? `/~${sub}` : ''
const Corner = () => {
if (me) {
@ -73,20 +67,23 @@ export default function Header () {
</Link>
<div>
<NavDropdown.Divider />
<Link href='/recent' passHref>
<Link href={prefix + '/recent'} passHref>
<NavDropdown.Item>recent</NavDropdown.Item>
</Link>
<Link href='/top/posts/week' passHref>
<NavDropdown.Item>top</NavDropdown.Item>
</Link>
{!prefix &&
<Link href='/top/posts/week' passHref>
<NavDropdown.Item>top</NavDropdown.Item>
</Link>}
{me
? (
<Link href='/post' passHref>
<Link href={prefix + '/post'} passHref>
<NavDropdown.Item>post</NavDropdown.Item>
</Link>
)
: <NavDropdown.Item onClick={signIn}>post</NavDropdown.Item>}
<NavDropdown.Item href='https://bitcoinerjobs.co' target='_blank'>jobs</NavDropdown.Item>
{sub
? <Link href='/' passHref><NavDropdown.Item>home</NavDropdown.Item></Link>
: <Link href='/~jobs' passHref><NavDropdown.Item>~jobs</NavDropdown.Item></Link>}
</div>
<NavDropdown.Divider />
<div className='d-flex align-items-center'>
@ -134,33 +131,50 @@ export default function Header () {
className={styles.navbarNav}
activeKey={path}
>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-none d-sm-block`}>STACKER NEWS</Navbar.Brand>
</Link>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-block d-sm-none`}>SN</Navbar.Brand>
</Link>
<div className='d-flex'>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-none d-sm-block`}>
{sub
? 'SN'
: 'STACKER NEWS'}
</Navbar.Brand>
</Link>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-block d-sm-none`}>
SN
</Navbar.Brand>
</Link>
{sub &&
<Link href={prefix} passHref>
<Navbar.Brand className={`${styles.brand} d-block`}>
<span>&nbsp;</span>{sub}
</Navbar.Brand>
</Link>}
</div>
<Nav.Item className='d-md-flex d-none'>
<Link href='/recent' passHref>
<Link href={prefix + '/recent'} passHref>
<Nav.Link className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className='d-md-flex d-none'>
<Link href='/top/posts/week' passHref>
<Nav.Link className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>
{!prefix &&
<Nav.Item className='d-md-flex d-none'>
<Link href='/top/posts/week' passHref>
<Nav.Link className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
<Nav.Item className='d-md-flex d-none'>
{me
? (
<Link href='/post' passHref>
<Link href={prefix + '/post'} passHref>
<Nav.Link className={styles.navLink}>post</Nav.Link>
</Link>
)
: <Nav.Link className={styles.navLink} onClick={signIn}>post</Nav.Link>}
</Nav.Item>
<Nav.Item className='d-md-flex d-none'>
<Nav.Link href='https://bitcoinerjobs.co' target='_blank' className={styles.navLink}>jobs</Nav.Link>
{sub
? <Link href='/' passHref><Nav.Link className={styles.navLink}>home</Nav.Link></Link>
: <Link href='/~jobs' passHref><Nav.Link className={styles.navLink}>~jobs</Nav.Link></Link>}
</Nav.Item>
<Nav.Item className='text-monospace nav-link'>
<Price />
@ -172,23 +186,3 @@ export default function Header () {
</>
)
}
export function HeaderPreview () {
return (
<>
<Container className='px-sm-0'>
{/* still need to set variant */}
<Navbar className={styles.navbar}>
<Nav className='w-100 justify-content-between flex-wrap align-items-center'>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-none d-sm-block`}>STACKER NEWS</Navbar.Brand>
</Link>
<Nav.Item className='text-monospace' style={{ opacity: '.5' }}>
<Price />
</Nav.Item>
</Nav>
</Navbar>
</Container>
</>
)
}

View File

@ -1,4 +1,4 @@
import Item from './item'
import Item, { ItemJob } from './item'
import Reply from './reply'
import Comment from './comment'
import Text from './text'
@ -95,12 +95,14 @@ function ItemEmbed ({ item }) {
}
function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item
return (
<Item item={item} {...props}>
<ItemComponent item={item} {...props}>
{item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />}
{!noReply && <Reply parentId={item.id} replyOpen />}
</Item>
</ItemComponent>
)
}

View File

@ -7,6 +7,9 @@ import Countdown from './countdown'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
import { formatSats } from '../lib/format'
import * as Yup from 'yup'
import Briefcase from '../svgs/briefcase-4-fill.svg'
function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
@ -14,6 +17,71 @@ function SearchTitle ({ title }) {
})
}
export function ItemJob ({ item, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
return (
<>
{rank
? (
<div className={styles.rank}>
{rank}
</div>)
: <div />}
<div className={`${styles.item}`}>
<Briefcase width={24} height={24} className={styles.case} />
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap d-inline`}>
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
</a>
</Link>
{/* eslint-disable-next-line */}
<a
className={`${styles.link}`}
target='_blank' href={(isEmail ? 'mailto:' : '') + item.url}
>
apply
</a>
</div>
<div className={`${styles.other}`}>
<span>{formatSats(item.maxBid)} sats</span>
<span> \ </span>
<Link href={`/items/${item.id}`} passHref>
<a className='text-reset'>{item.ncomments} comments</a>
</Link>
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
</span>
{item.mine &&
<>
<span> \ </span>
<Link href={`/items/${item.id}/edit`} passHref>
<a className='text-reset'>
edit
</a>
</Link>
</>}
</div>
</div>
</div>
{children && (
<div className={`${styles.children}`}>
{children}
</div>
)}
</>
)
}
export default function Item ({ item, rank, children }) {
const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000

View File

@ -21,6 +21,14 @@
margin-right: .2rem;
}
.case {
fill: #a5a5a5;
margin-right: .2rem;
margin-left: .2rem;
margin-top: .2rem;
padding: 0 2px;
}
.linkSmall {
width: 128px;
display: inline-block;

View File

@ -1,20 +1,20 @@
import { useQuery } from '@apollo/client'
import Item, { ItemSkeleton } from './item'
import Item, { ItemJob, ItemSkeleton } from './item'
import styles from './items.module.css'
import { MORE_ITEMS } from '../fragments/items'
import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer'
import React from 'react'
import Comment from './comment'
export default function Items ({ variables, rank, items, pins, cursor }) {
const { data, fetchMore } = useQuery(MORE_ITEMS, { variables })
export default function Items ({ variables = {}, rank, items, pins, cursor }) {
const { data, fetchMore } = useQuery(ITEMS, { variables })
if (!data && !items) {
return <ItemsSkeleton rank={rank} />
}
if (data) {
({ moreItems: { items, pins, cursor } } = data)
({ items: { items, pins, cursor } } = data)
}
const pinMap = pins?.reduce((a, p) => { a[p.position] = p; return a }, {})
@ -24,10 +24,12 @@ export default function Items ({ variables, rank, items, pins, cursor }) {
<div className={styles.grid}>
{items.map((item, i) => (
<React.Fragment key={item.id}>
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} key={pinMap[i + 1].id} />}
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
{item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
: <Item item={item} rank={rank && i + 1} key={item.id} />}
: (item.maxBid
? <ItemJob item={item} rank={rank && i + 1} />
: <Item item={item} rank={rank && i + 1} />)}
</React.Fragment>
))}
</div>

183
components/job-form.js Normal file
View File

@ -0,0 +1,183 @@
import { Form, Input, MarkdownInput, SubmitButton } from './form'
import TextareaAutosize from 'react-textarea-autosize'
import { InputGroup, Modal } from 'react-bootstrap'
import * as Yup from 'yup'
import { useEffect, useState } from 'react'
import Info from '../svgs/information-fill.svg'
import AccordianItem from './accordian-item'
import styles from '../styles/post.module.css'
import { useLazyQuery, gql, useMutation } from '@apollo/client'
import { useRouter } from 'next/router'
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
return this.test({
name: 'or',
message: msg,
test: value => {
if (Array.isArray(schemas) && schemas.length > 1) {
const resee = schemas.map(schema => schema.isValidSync(value))
return resee.some(res => res)
} else {
throw new TypeError('Schemas is not correct array schema')
}
},
exclusive: false
})
})
function satsMo2Min (monthly) {
return Number.parseFloat(monthly / 30 / 24 / 60).toFixed(2)
}
// need to recent list items
export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter()
const [pull, setPull] = useState(satsMo2Min(item?.maxBid || sub.baseCost))
const [info, setInfo] = useState()
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $text: String!, $url: String!, $maxBid: Int!) {
upsertJob(sub: "${sub.name}", id: $id title: $title, text: $text, url: $url, maxBid: $maxBid) {
id
}
}`
)
const JobSchema = Yup.object({
title: Yup.string().required('required').trim(),
text: Yup.string().required('required').trim(),
url: Yup.string()
.or([Yup.string().email(), Yup.string().url()], 'invalid url or email')
.required('Required'),
maxBid: Yup.number('must be number')
.integer('must be integer').min(sub.baseCost, 'must be at least 10000')
.max(100000000, 'must be less than 100000000')
.test('multiple', 'must be a multiple of 1000 sats', (val) => val % 1000 === 0)
})
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || sub.baseCost
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setPull(satsMo2Min(initialMaxBid))
}, [])
return (
<>
<Modal
show={info}
onHide={() => setInfo(false)}
>
<div className={styles.close} onClick={() => setInfo(false)}>X</div>
<Modal.Body>
<ol className='font-weight-bold'>
<li>The higher your bid the higher your job will rank</li>
<li>The minimum bid is {sub.baseCost} sats/mo</li>
<li>You can increase or decrease your bid at anytime</li>
<li>You can edit or remove your job at anytime</li>
<li>Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again</li>
</ol>
<AccordianItem
header={<div className='font-weight-bold'>How does ranking work in detail?</div>}
body={
<div>
<ol>
<li>You only pay as many sats/mo as required to maintain your position relative to other
posts and only up to your max bid.
</li>
<li>Your sats/mo must be a multiple of 1000 sats</li>
</ol>
<div className='font-weight-bold text-muted'>By example</div>
<p>If your post's (A's) max bid is higher than another post (B) by at least
1000 sats/mo your post will rank higher and your wallet will pay 1000
sats/mo more than B.
</p>
<p>If another post (C) comes along whose max bid is higher than B's but less
than your's (A's), C will pay 1000 sats/mo more than B, and you will pay 1000 sats/mo
more than C.
</p>
<p>If a post (D) comes along whose max bid is higher than your's (A's), D
will pay 1000 stat/mo more than you (A), and the amount you (A) pays won't
change.
</p>
</div>
}
/>
</Modal.Body>
</Modal>
<Form
className='py-5'
initial={{
title: item?.title || '',
text: item?.text || '',
url: item?.url || '',
maxBid: item?.maxBid || sub.baseCost
}}
schema={JobSchema}
storageKeyPrefix={storageKeyPrefix}
onSubmit={(async ({ maxBid, ...values }) => {
const variables = { sub: sub.name, maxBid: Number(maxBid), ...values }
if (item) {
variables.id = item.id
}
const { error } = await upsertJob({ variables })
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
router.push(`/items/${item.id}`)
} else {
router.push(`/$${sub.name}/recent`)
}
})}
>
<Input
label='title'
name='title'
required
autoFocus
/>
<MarkdownInput
label='description'
name='text'
as={TextareaAutosize}
minRows={6}
required
/>
<Input
label={<>how to apply <small className='text-muted ml-2'>url or email address</small></>}
name='url'
required
/>
<Input
label={
<div className='d-flex align-items-center'>max bid
<Info width={18} height={18} className='fill-theme-color pointer ml-1' onClick={() => setInfo(true)} />
</div>
}
name='maxBid'
onChange={async (formik, e) => {
if (e.target.value >= sub.baseCost && e.target.value <= 100000000) {
setPull(satsMo2Min(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setPull(satsMo2Min(sub.baseCost))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/month</InputGroup.Text>}
hint={<span className='text-muted'>up to {pull} sats/min will be pulled from your wallet</span>}
/>
<div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
</>
)
}

View File

@ -6,15 +6,18 @@ import Footer from './footer'
import Seo from './seo'
import Search from './search'
export default function Layout ({ noContain, noFooter, noFooterLinks, containClassName, noSeo, children }) {
export default function Layout ({
sub, noContain, noFooter, noFooterLinks,
containClassName, noSeo, children
}) {
return (
<>
{!noSeo && <Seo />}
{!noSeo && <Seo sub={sub} />}
<LightningProvider>
<Head>
<meta name='viewport' content='initial-scale=1.0, width=device-width' />
</Head>
<Header />
<Header sub={sub} />
{noContain
? children
: (
@ -23,7 +26,7 @@ export default function Layout ({ noContain, noFooter, noFooterLinks, containCla
</Container>
)}
{!noFooter && <Footer noLinks={noFooterLinks} />}
{!noContain && <Search />}
{!noContain && <Search sub={sub} />}
</LightningProvider>
</>
)

View File

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
import { Form, Input, SubmitButton } from './form'
import { useRouter } from 'next/router'
export default function Search () {
export default function Search ({ sub }) {
const router = useRouter()
const [searching, setSearching] = useState(router.query.q)
const [q, setQ] = useState(router.query.q)
@ -37,7 +37,11 @@ export default function Search () {
className={`w-auto ${styles.active}`}
onSubmit={async ({ q }) => {
if (q.trim() !== '') {
router.push(`/search?q=${q}`)
let prefix = ''
if (sub) {
prefix = `/~${sub}`
}
router.push(prefix + `/search?q=${q}`)
}
}}
>

View File

@ -2,10 +2,11 @@ import { NextSeo } from 'next-seo'
import { useRouter } from 'next/router'
import RemoveMarkdown from 'remove-markdown'
export function SeoSearch () {
export function SeoSearch ({ sub }) {
const router = useRouter()
const title = `${router.query.q} \\ stacker news`
const desc = `SN search: ${router.query.q}`
const subStr = sub ? ` ~${sub}` : ''
const title = `${router.query.q} \\ stacker news${subStr}`
const desc = `SN${subStr} search: ${router.query.q}`
return (
<NextSeo
@ -29,18 +30,25 @@ export function SeoSearch () {
)
}
export default function Seo ({ item, user }) {
// for a sub we need
// item seo
// index page seo
// recent page seo
export default function Seo ({ sub, item, user }) {
const router = useRouter()
const pathNoQuery = router.asPath.split('?')[0]
const defaultTitle = pathNoQuery.slice(1)
const snStr = `stacker news${sub ? ` ~${sub}` : ''}`
let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}stacker news`
let desc = "It's like Hacker News but we pay you Bitcoin."
if (item) {
if (item.title) {
fullTitle = `${item.title} \\ stacker news`
fullTitle = `${item.title} \\ ${snStr}`
} else if (item.root) {
fullTitle = `reply on: ${item.root.title} \\ stacker news`
fullTitle = `reply on: ${item.root.title} \\ ${snStr}`
}
// at least for now subs (ie the only one is jobs) will always have text
if (item.text) {
desc = RemoveMarkdown(item.text)
if (desc) {

View File

@ -17,10 +17,18 @@ export const ITEM_FIELDS = gql`
boost
meSats
ncomments
maxBid
sub {
name
baseCost
}
mine
root {
id
title
sub {
name
}
user {
name
id
@ -28,11 +36,11 @@ export const ITEM_FIELDS = gql`
}
}`
export const MORE_ITEMS = gql`
export const ITEMS = gql`
${ITEM_FIELDS}
query MoreItems($sort: String!, $cursor: String, $name: String, $within: String) {
moreItems(sort: $sort, cursor: $cursor, name: $name, within: $within) {
query items($sub: String, $sort: String, $cursor: String, $name: String, $within: String) {
items(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
cursor
items {
...ItemFields

54
fragments/subs.js Normal file
View File

@ -0,0 +1,54 @@
import { gql } from '@apollo/client'
import { ITEM_FIELDS } from './items'
export const SUB_FIELDS = gql`
fragment SubFields on Sub {
name
postTypes
rankingType
baseCost
}`
export const SUB = gql`
${SUB_FIELDS}
query Sub($sub: ID!) {
sub(name: $sub) {
...SubFields
}
}`
export const SUB_ITEMS = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubRecent($sub: String, $sort: String) {
sub(name: $sub) {
...SubFields
}
items(sub: $sub, sort: $sort) {
cursor
items {
...ItemFields
}
}
}
`
export const SUB_SEARCH = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubSearch($sub: String, $q: String, $cursor: String) {
sub(name: $sub) {
...SubFields
}
search(q: $q, sub: $sub, cursor: $cursor) {
cursor
items {
...ItemFields
text
searchTitle
searchText
}
}
}
`

View File

@ -85,14 +85,14 @@ export const USER_WITH_POSTS = gql`
${USER_FIELDS}
${ITEM_WITH_COMMENTS}
${ITEM_FIELDS}
query UserWithPosts($name: String!, $sort: String!) {
query UserWithPosts($name: String!) {
user(name: $name) {
...UserFields
bio {
...ItemWithComments
}
}
moreItems(sort: $sort, name: $name) {
items(sort: "user", name: $name) {
cursor
items {
...ItemFields

View File

@ -38,8 +38,8 @@ export default function getApolloClient () {
}
}
},
moreItems: {
keyArgs: ['sort', 'name', 'within'],
items: {
keyArgs: ['sub', 'sort', 'name', 'within'],
merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming

7
lib/format.js Normal file
View File

@ -0,0 +1,7 @@
export const formatSats = n => {
if (n < 1e4) return n
if (n >= 1e4 && n < 1e6) return +(n / 1e3).toFixed(1) + 'k'
if (n >= 1e6 && n < 1e9) return +(n / 1e6).toFixed(1) + 'm'
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b'
if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't'
}

View File

@ -60,8 +60,12 @@ module.exports = withPlausibleProxy()({
destination: '/api/lnurlp/:username'
},
{
source: '/$:sub',
destination: '/$/:sub'
source: '/~:sub',
destination: '/~/:sub'
},
{
source: '/~:sub/:slug*',
destination: '/~/:sub/:slug*'
}
]
},

13229
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@
"nextjs-progressbar": "^0.0.13",
"node-s3-url-encode": "^0.0.4",
"page-metadata-parser": "^1.1.4",
"pageres": "^6.2.3",
"pageres": "^6.3.0",
"pg-boss": "^7.0.2",
"prisma": "^2.25.0",
"qrcode.react": "^1.0.1",

View File

@ -1,3 +0,0 @@
export default function Sub () {
return <h1>hi</h1>
}

View File

@ -23,7 +23,7 @@ export default function UserComments (
<UserHeader user={user} />
<CommentsFlat
comments={comments} cursor={cursor}
variables={{ name: user.name, sort: 'user' }} includeParent noReply
variables={{ name: user.name }} includeParent noReply
/>
</Layout>
)

View File

@ -6,14 +6,14 @@ import Items from '../../components/items'
import { USER_WITH_POSTS } from '../../fragments/users'
import { getGetServerSideProps } from '../../api/ssrApollo'
export const getServerSideProps = getGetServerSideProps(USER_WITH_POSTS, { sort: 'user' })
export const getServerSideProps = getGetServerSideProps(USER_WITH_POSTS)
export default function UserPosts ({ data: { user, moreItems: { items, cursor } } }) {
export default function UserPosts ({ data: { user, items: { items, cursor } } }) {
const { data } = useQuery(USER_WITH_POSTS,
{ variables: { name: user.name, sort: 'user' } })
{ variables: { name: user.name } })
if (data) {
({ user, moreItems: { items, cursor } } = data)
({ user, items: { items, cursor } } = data)
}
return (
@ -23,7 +23,7 @@ export default function UserPosts ({ data: { user, moreItems: { items, cursor }
<div className='mt-2'>
<Items
items={items} cursor={cursor}
variables={{ sort: 'user', name: user.name }}
variables={{ name: user.name }}
/>
</div>
</Layout>

View File

@ -1,17 +1,16 @@
import Layout from '../components/layout'
import Items from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo'
import { MORE_ITEMS } from '../fragments/items'
import { ITEMS } from '../fragments/items'
const variables = { sort: 'hot' }
export const getServerSideProps = getGetServerSideProps(MORE_ITEMS, variables)
export const getServerSideProps = getGetServerSideProps(ITEMS)
export default function Index ({ data: { moreItems: { items, pins, cursor } } }) {
export default function Index ({ data: { items: { items, pins, cursor } } }) {
return (
<Layout>
<Items
items={items} pins={pins} cursor={cursor}
variables={variables} rank
rank
/>
</Layout>
)

View File

@ -3,6 +3,7 @@ import { getGetServerSideProps } from '../../../api/ssrApollo'
import { DiscussionForm } from '../../../components/discussion-form'
import { LinkForm } from '../../../components/link-form'
import LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form'
export const getServerSideProps = getGetServerSideProps(ITEM, null, 'item')
@ -10,10 +11,12 @@ export default function PostEdit ({ data: { item } }) {
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
return (
<LayoutCenter>
{item.url
? <LinkForm item={item} editThreshold={editThreshold} />
: <DiscussionForm item={item} editThreshold={editThreshold} />}
<LayoutCenter sub={item.sub?.name}>
{item.maxBid
? <JobForm item={item} sub={item.sub} />
: (item.url
? <LinkForm item={item} editThreshold={editThreshold} />
: <DiscussionForm item={item} editThreshold={editThreshold} />)}
</LayoutCenter>
)
}

View File

@ -15,9 +15,11 @@ export default function AnItem ({ data: { item } }) {
({ item } = data)
}
const sub = item.sub?.name || item.root?.sub?.name
return (
<Layout noSeo>
<Seo item={item} />
<Layout sub={sub} noSeo>
<Seo item={item} sub={sub} />
<ItemFull item={item} />
</Layout>
)

View File

@ -1,12 +1,12 @@
import Layout from '../components/layout'
import Items from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo'
import { MORE_ITEMS } from '../fragments/items'
import { ITEMS } from '../fragments/items'
const variables = { sort: 'recent' }
export const getServerSideProps = getGetServerSideProps(MORE_ITEMS, { sort: 'recent' })
export const getServerSideProps = getGetServerSideProps(ITEMS, variables)
export default function Index ({ data: { moreItems: { items, cursor } } }) {
export default function Index ({ data: { items: { items, cursor } } }) {
return (
<Layout>
<Items

View File

@ -1,7 +1,7 @@
import getSSRApolloClient from '../api/ssrApollo'
import generateRssFeed from '../lib/rss'
import { MORE_ITEMS } from '../fragments/items'
import { ITEMS } from '../fragments/items'
export default function RssFeed () {
return null
@ -10,9 +10,8 @@ export default function RssFeed () {
export async function getServerSideProps ({ req, res }) {
const emptyProps = { props: {} } // to avoid server side warnings
const client = await getSSRApolloClient(req)
const { error, data: { moreItems: { items } } } = await client.query({
query: MORE_ITEMS,
variables: { sort: 'hot' }
const { error, data: { items: { items } } } = await client.query({
query: ITEMS
})
if (!items || error) return emptyProps

View File

@ -2,13 +2,13 @@ import Layout from '../../../components/layout'
import Items from '../../../components/items'
import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../../api/ssrApollo'
import { MORE_ITEMS } from '../../../fragments/items'
import { ITEMS } from '../../../fragments/items'
import TopHeader from '../../../components/top-header'
export const getServerSideProps = getGetServerSideProps(MORE_ITEMS, { sort: 'top' })
export const getServerSideProps = getGetServerSideProps(ITEMS, { sort: 'top' })
export default function Index ({ data: { moreItems: { items, cursor } } }) {
export default function Index ({ data: { items: { items, cursor } } }) {
const router = useRouter()
return (

17
pages/~/[sub]/index.js Normal file
View File

@ -0,0 +1,17 @@
import { getGetServerSideProps } from '../../../api/ssrApollo'
import Items from '../../../components/items'
import Layout from '../../../components/layout'
import { SUB_ITEMS } from '../../../fragments/subs'
export const getServerSideProps = getGetServerSideProps(SUB_ITEMS)
export default function Sub ({ data: { sub: { name }, items: { items, cursor } } }) {
return (
<Layout sub={name}>
<Items
items={items} cursor={cursor} rank
variables={{ sub: name }}
/>
</Layout>
)
}

15
pages/~/[sub]/post.js Normal file
View File

@ -0,0 +1,15 @@
import { getGetServerSideProps } from '../../../api/ssrApollo'
import { SUB } from '../../../fragments/subs'
import LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form'
export const getServerSideProps = getGetServerSideProps(SUB)
// need to recent list items
export default function Post ({ data: { sub } }) {
return (
<LayoutCenter sub={sub.name}>
<JobForm sub={sub} />
</LayoutCenter>
)
}

19
pages/~/[sub]/recent.js Normal file
View File

@ -0,0 +1,19 @@
import { getGetServerSideProps } from '../../../api/ssrApollo'
import Items from '../../../components/items'
import Layout from '../../../components/layout'
import { SUB_ITEMS } from '../../../fragments/subs'
const variables = { sort: 'recent' }
export const getServerSideProps = getGetServerSideProps(SUB_ITEMS, variables)
// need to recent list items
export default function Sub ({ data: { sub: { name }, items: { items, cursor } } }) {
return (
<Layout sub={name}>
<Items
items={items} cursor={cursor}
variables={{ sub: name, ...variables }} rank
/>
</Layout>
)
}

21
pages/~/[sub]/search.js Normal file
View File

@ -0,0 +1,21 @@
import Layout from '../../../components/layout'
import { getGetServerSideProps } from '../../../api/ssrApollo'
import SearchItems from '../../../components/search-items'
import { useRouter } from 'next/router'
import { SeoSearch } from '../../../components/seo'
import { SUB_SEARCH } from '../../../fragments/subs'
export const getServerSideProps = getGetServerSideProps(SUB_SEARCH, null, null, 'q')
export default function Index ({ data: { sub: { name }, search: { items, cursor } } }) {
const router = useRouter()
return (
<Layout sub={name} noSeo>
<SeoSearch sub={name} />
<SearchItems
items={items} cursor={cursor} variables={{ q: router.query?.q, sub: name }}
/>
</Layout>
)
}

View File

@ -0,0 +1,35 @@
-- CreateEnum
CREATE TYPE "PostType" AS ENUM ('LINK', 'DISCUSSION', 'JOB');
-- CreateEnum
CREATE TYPE "RankingType" AS ENUM ('WOT', 'RECENT', 'AUCTION');
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "location" TEXT,
ADD COLUMN "longitude" DOUBLE PRECISION,
ADD COLUMN "maxBid" INTEGER,
ADD COLUMN "maxSalary" INTEGER,
ADD COLUMN "minSalary" INTEGER,
ADD COLUMN "remote" BOOLEAN,
ADD COLUMN "subName" CITEXT;
-- CreateTable
CREATE TABLE "Sub" (
"name" CITEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"postTypes" "PostType"[],
"rankingType" "RankingType" NOT NULL,
"baseCost" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("name")
);
-- AddForeignKey
ALTER TABLE "Item" ADD FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "Sub" (name, created_at, updated_at, "postTypes", "rankingType", "baseCost")
VALUES ('jobs', now(), now(), '{JOB}', 'AUCTION', 10000)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Sub" ADD COLUMN "desc" TEXT;
UPDATE "Sub" SET desc = 'jobs at bitcoin and lightning companies' WHERE name = 'jobs';

View File

@ -98,6 +98,19 @@ model Item {
pin Pin? @relation(fields: [pinId], references: [id])
pinId Int?
// if sub is null, this is the main sub
sub Sub? @relation(fields: [subName], references: [name])
subName String? @db.Citext
// fields exclusively for job post types right now
minSalary Int?
maxSalary Int?
maxBid Int?
location String?
latitude Float?
longitude Float?
remote Boolean?
User User[] @relation("Item")
@@index([createdAt])
@ -105,6 +118,30 @@ model Item {
@@index([parentId])
}
enum PostType {
LINK
DISCUSSION
JOB
}
enum RankingType {
WOT
RECENT
AUCTION
}
model Sub {
name String @id @db.Citext
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
postTypes PostType[]
rankingType RankingType
baseCost Int @default(1)
desc String?
Item Item[]
}
// the active pin is the latest one when it's a recurring cron
model Pin {
id Int @id @default(autoincrement())

View File

@ -63,6 +63,10 @@ $tooltip-bg: #5c8001;
color: var(--theme-grey) !important;
}
ol, ul, dl {
padding-inline-start: 2rem;
}
mark {
background-color: var(--primary);
padding: 0 0.2rem;
@ -364,6 +368,10 @@ textarea.form-control {
fill: #c03221;
}
.fill-theme-color {
fill: var(--theme-color);
}
.text-underline {
text-decoration: underline;
}

13
styles/post.module.css Normal file
View File

@ -0,0 +1,13 @@
.close {
cursor: pointer;
display: flex;
margin-left: auto;
padding-top: 1rem;
padding-right: 1.5rem;
font-family: 'lightning';
font-size: 150%;
}
.close:hover {
opacity: 0.7;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M9 13v3h6v-3h7v7a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-7h7zm2-2h2v3h-2v-3zM7 5V2a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v3h4a1 1 0 0 1 1 1v5h-7V9H9v2H2V6a1 1 0 0 1 1-1h4zm2-2v2h6V3H9z"/></svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z"/></svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -14,6 +14,10 @@ const ITEM_SEARCH_FIELDS = gql`
user {
name
}
sub {
name
}
maxBid
upvotes
sats
boost