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 lnurl from './lnurl'
import notifications from './notifications' import notifications from './notifications'
import invite from './invite' 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 { export default {
Query: { Query: {
moreItems: async (parent, { sort, cursor, name, within }, { me, models }) => { items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) 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) { switch (sort) {
case 'user': case 'user':
@ -97,73 +101,96 @@ export default {
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
break break
case 'hot': case 'recent':
// 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:
items = await models.$queryRaw(` items = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL
${subClause(3)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $2 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) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break 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 { return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
@ -264,7 +291,7 @@ export default {
comments: async (parent, { id, sort }, { models }) => { comments: async (parent, { id, sort }, { models }) => {
return comments(models, id, sort) 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) const decodedCursor = decodeCursor(cursor)
let sitems let sitems
@ -276,47 +303,52 @@ export default {
body: { body: {
query: { query: {
bool: { bool: {
must: { must: [
bool: { sub
should: [ ? { term: { 'sub.name': sub } }
{ : { bool: { must_not: { exists: { field: 'sub.name' } } } },
{
bool: {
should: [
{
// all terms are matched in fields // all terms are matched in fields
multi_match: { multi_match: {
query, query,
type: 'most_fields', type: 'most_fields',
fields: ['title^20', 'text'], fields: ['title^20', 'text'],
minimum_should_match: '100%', minimum_should_match: '100%',
boost: 400 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
// 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: { filter: {
range: { range: {
createdAt: { createdAt: {
@ -356,6 +388,36 @@ export default {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items 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 }) 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 }) => { createComment: async (parent, { text, parentId }, { me, models }) => {
if (!text) { if (!text) {
throw new UserInputError('comment must have text', { argumentName: 'text' }) throw new UserInputError('comment must have text', { argumentName: 'text' })
@ -486,6 +593,13 @@ export default {
}, },
Item: { 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 }) => { position: async (item, args, { models }) => {
if (!item.pinId) { if (!item.pinId) {
return null return null
@ -735,7 +849,8 @@ function nestComments (flat, parentId) {
// we have to do our own query because ltree is unsupported // we have to do our own query because ltree is unsupported
export const SELECT = export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, `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' 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 lnurl from './lnurl'
import notifications from './notifications' import notifications from './notifications'
import invite from './invite' import invite from './invite'
import sub from './sub'
const link = gql` const link = gql`
type Query { 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` export default gql`
extend type Query { 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 moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
item(id: ID!): Item item(id: ID!): Item
comments(id: ID!, sort: String): [Item!]! comments(id: ID!, sort: String): [Item!]!
pageTitle(url: String!): String pageTitle(url: String!): String
dupes(url: String!): [Item!] dupes(url: String!): [Item!]
allItems(cursor: String): Items 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 { type ItemActResult {
@ -24,6 +25,7 @@ export default gql`
updateDiscussion(id: ID!, title: String!, text: String): Item! updateDiscussion(id: ID!, title: String!, text: String): Item!
createComment(text: String!, parentId: ID!): Item! createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): 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! act(id: ID!, sats: Int): ItemActResult!
} }
@ -63,5 +65,7 @@ export default gql`
path: String path: String
position: Int position: Int
prior: 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 styles from './form.module.css'
import Text from '../components/text' 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() const { isSubmitting, setFieldValue } = useFormikContext()
return ( return (
<Button <Button
@ -249,6 +251,7 @@ export function Form ({
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>} {error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
{storageKeyPrefix {storageKeyPrefix
? React.Children.map(children, (child) => { ? React.Children.map(children, (child) => {
// if child has a type it's a dom element
if (child) { if (child) {
return React.cloneElement(child, { return React.cloneElement(child, {
storageKeyPrefix storageKeyPrefix

View File

@ -11,14 +11,7 @@ import { signOut, signIn } from 'next-auth/client'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { randInRange } from '../lib/rand' import { randInRange } from '../lib/rand'
import { formatSats } from '../lib/format'
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'
}
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
if (!me) return null if (!me) return null
@ -26,11 +19,12 @@ function WalletSummary ({ me }) {
return `${formatSats(me.sats)} \\ ${formatSats(me.stacked)}` return `${formatSats(me.sats)} \\ ${formatSats(me.stacked)}`
} }
export default function Header () { export default function Header ({ sub }) {
const router = useRouter() const router = useRouter()
const path = router.asPath.split('?')[0] const path = router.asPath.split('?')[0]
const [fired, setFired] = useState() const [fired, setFired] = useState()
const me = useMe() const me = useMe()
const prefix = sub ? `/~${sub}` : ''
const Corner = () => { const Corner = () => {
if (me) { if (me) {
@ -73,20 +67,23 @@ export default function Header () {
</Link> </Link>
<div> <div>
<NavDropdown.Divider /> <NavDropdown.Divider />
<Link href='/recent' passHref> <Link href={prefix + '/recent'} passHref>
<NavDropdown.Item>recent</NavDropdown.Item> <NavDropdown.Item>recent</NavDropdown.Item>
</Link> </Link>
<Link href='/top/posts/week' passHref> {!prefix &&
<NavDropdown.Item>top</NavDropdown.Item> <Link href='/top/posts/week' passHref>
</Link> <NavDropdown.Item>top</NavDropdown.Item>
</Link>}
{me {me
? ( ? (
<Link href='/post' passHref> <Link href={prefix + '/post'} passHref>
<NavDropdown.Item>post</NavDropdown.Item> <NavDropdown.Item>post</NavDropdown.Item>
</Link> </Link>
) )
: <NavDropdown.Item onClick={signIn}>post</NavDropdown.Item>} : <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> </div>
<NavDropdown.Divider /> <NavDropdown.Divider />
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
@ -134,33 +131,50 @@ export default function Header () {
className={styles.navbarNav} className={styles.navbarNav}
activeKey={path} activeKey={path}
> >
<Link href='/' passHref> <div className='d-flex'>
<Navbar.Brand className={`${styles.brand} d-none d-sm-block`}>STACKER NEWS</Navbar.Brand> <Link href='/' passHref>
</Link> <Navbar.Brand className={`${styles.brand} d-none d-sm-block`}>
<Link href='/' passHref> {sub
<Navbar.Brand className={`${styles.brand} d-block d-sm-none`}>SN</Navbar.Brand> ? 'SN'
</Link> : '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'> <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> <Nav.Link className={styles.navLink}>recent</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
<Nav.Item className='d-md-flex d-none'> {!prefix &&
<Link href='/top/posts/week' passHref> <Nav.Item className='d-md-flex d-none'>
<Nav.Link className={styles.navLink}>top</Nav.Link> <Link href='/top/posts/week' passHref>
</Link> <Nav.Link className={styles.navLink}>top</Nav.Link>
</Nav.Item> </Link>
</Nav.Item>}
<Nav.Item className='d-md-flex d-none'> <Nav.Item className='d-md-flex d-none'>
{me {me
? ( ? (
<Link href='/post' passHref> <Link href={prefix + '/post'} passHref>
<Nav.Link className={styles.navLink}>post</Nav.Link> <Nav.Link className={styles.navLink}>post</Nav.Link>
</Link> </Link>
) )
: <Nav.Link className={styles.navLink} onClick={signIn}>post</Nav.Link>} : <Nav.Link className={styles.navLink} onClick={signIn}>post</Nav.Link>}
</Nav.Item> </Nav.Item>
<Nav.Item className='d-md-flex d-none'> <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>
<Nav.Item className='text-monospace nav-link'> <Nav.Item className='text-monospace nav-link'>
<Price /> <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 Reply from './reply'
import Comment from './comment' import Comment from './comment'
import Text from './text' import Text from './text'
@ -95,12 +95,14 @@ function ItemEmbed ({ item }) {
} }
function TopLevelItem ({ item, noReply, ...props }) { function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item
return ( return (
<Item item={item} {...props}> <ItemComponent item={item} {...props}>
{item.text && <ItemText item={item} />} {item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />} {item.url && <ItemEmbed item={item} />}
{!noReply && <Reply parentId={item.id} replyOpen />} {!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 { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg' import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' 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 }) { function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { 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 }) { export default function Item ({ item, rank, children }) {
const mine = item.mine const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000

View File

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

View File

@ -1,20 +1,20 @@
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import Item, { ItemSkeleton } from './item' import Item, { ItemJob, ItemSkeleton } from './item'
import styles from './items.module.css' import styles from './items.module.css'
import { MORE_ITEMS } from '../fragments/items' import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import React from 'react' import React from 'react'
import Comment from './comment' import Comment from './comment'
export default function Items ({ variables, rank, items, pins, cursor }) { export default function Items ({ variables = {}, rank, items, pins, cursor }) {
const { data, fetchMore } = useQuery(MORE_ITEMS, { variables }) const { data, fetchMore } = useQuery(ITEMS, { variables })
if (!data && !items) { if (!data && !items) {
return <ItemsSkeleton rank={rank} /> return <ItemsSkeleton rank={rank} />
} }
if (data) { if (data) {
({ moreItems: { items, pins, cursor } } = data) ({ items: { items, pins, cursor } } = data)
} }
const pinMap = pins?.reduce((a, p) => { a[p.position] = p; return a }, {}) 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}> <div className={styles.grid}>
{items.map((item, i) => ( {items.map((item, i) => (
<React.Fragment key={item.id}> <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 {item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></> ? <><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> </React.Fragment>
))} ))}
</div> </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 Seo from './seo'
import Search from './search' 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 ( return (
<> <>
{!noSeo && <Seo />} {!noSeo && <Seo sub={sub} />}
<LightningProvider> <LightningProvider>
<Head> <Head>
<meta name='viewport' content='initial-scale=1.0, width=device-width' /> <meta name='viewport' content='initial-scale=1.0, width=device-width' />
</Head> </Head>
<Header /> <Header sub={sub} />
{noContain {noContain
? children ? children
: ( : (
@ -23,7 +26,7 @@ export default function Layout ({ noContain, noFooter, noFooterLinks, containCla
</Container> </Container>
)} )}
{!noFooter && <Footer noLinks={noFooterLinks} />} {!noFooter && <Footer noLinks={noFooterLinks} />}
{!noContain && <Search />} {!noContain && <Search sub={sub} />}
</LightningProvider> </LightningProvider>
</> </>
) )

View File

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
import { Form, Input, SubmitButton } from './form' import { Form, Input, SubmitButton } from './form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
export default function Search () { export default function Search ({ sub }) {
const router = useRouter() const router = useRouter()
const [searching, setSearching] = useState(router.query.q) const [searching, setSearching] = useState(router.query.q)
const [q, setQ] = useState(router.query.q) const [q, setQ] = useState(router.query.q)
@ -37,7 +37,11 @@ export default function Search () {
className={`w-auto ${styles.active}`} className={`w-auto ${styles.active}`}
onSubmit={async ({ q }) => { onSubmit={async ({ q }) => {
if (q.trim() !== '') { 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 { useRouter } from 'next/router'
import RemoveMarkdown from 'remove-markdown' import RemoveMarkdown from 'remove-markdown'
export function SeoSearch () { export function SeoSearch ({ sub }) {
const router = useRouter() const router = useRouter()
const title = `${router.query.q} \\ stacker news` const subStr = sub ? ` ~${sub}` : ''
const desc = `SN search: ${router.query.q}` const title = `${router.query.q} \\ stacker news${subStr}`
const desc = `SN${subStr} search: ${router.query.q}`
return ( return (
<NextSeo <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 router = useRouter()
const pathNoQuery = router.asPath.split('?')[0] const pathNoQuery = router.asPath.split('?')[0]
const defaultTitle = pathNoQuery.slice(1) const defaultTitle = pathNoQuery.slice(1)
const snStr = `stacker news${sub ? ` ~${sub}` : ''}`
let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}stacker news` let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}stacker news`
let desc = "It's like Hacker News but we pay you Bitcoin." let desc = "It's like Hacker News but we pay you Bitcoin."
if (item) { if (item) {
if (item.title) { if (item.title) {
fullTitle = `${item.title} \\ stacker news` fullTitle = `${item.title} \\ ${snStr}`
} else if (item.root) { } 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) { if (item.text) {
desc = RemoveMarkdown(item.text) desc = RemoveMarkdown(item.text)
if (desc) { if (desc) {

View File

@ -17,10 +17,18 @@ export const ITEM_FIELDS = gql`
boost boost
meSats meSats
ncomments ncomments
maxBid
sub {
name
baseCost
}
mine mine
root { root {
id id
title title
sub {
name
}
user { user {
name name
id id
@ -28,11 +36,11 @@ export const ITEM_FIELDS = gql`
} }
}` }`
export const MORE_ITEMS = gql` export const ITEMS = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
query MoreItems($sort: String!, $cursor: String, $name: String, $within: String) { query items($sub: String, $sort: String, $cursor: String, $name: String, $within: String) {
moreItems(sort: $sort, cursor: $cursor, name: $name, within: $within) { items(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
cursor cursor
items { items {
...ItemFields ...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} ${USER_FIELDS}
${ITEM_WITH_COMMENTS} ${ITEM_WITH_COMMENTS}
${ITEM_FIELDS} ${ITEM_FIELDS}
query UserWithPosts($name: String!, $sort: String!) { query UserWithPosts($name: String!) {
user(name: $name) { user(name: $name) {
...UserFields ...UserFields
bio { bio {
...ItemWithComments ...ItemWithComments
} }
} }
moreItems(sort: $sort, name: $name) { items(sort: "user", name: $name) {
cursor cursor
items { items {
...ItemFields ...ItemFields

View File

@ -38,8 +38,8 @@ export default function getApolloClient () {
} }
} }
}, },
moreItems: { items: {
keyArgs: ['sort', 'name', 'within'], keyArgs: ['sub', 'sort', 'name', 'within'],
merge (existing, incoming) { merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) { if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming 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' destination: '/api/lnurlp/:username'
}, },
{ {
source: '/$:sub', source: '/~:sub',
destination: '/$/: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", "nextjs-progressbar": "^0.0.13",
"node-s3-url-encode": "^0.0.4", "node-s3-url-encode": "^0.0.4",
"page-metadata-parser": "^1.1.4", "page-metadata-parser": "^1.1.4",
"pageres": "^6.2.3", "pageres": "^6.3.0",
"pg-boss": "^7.0.2", "pg-boss": "^7.0.2",
"prisma": "^2.25.0", "prisma": "^2.25.0",
"qrcode.react": "^1.0.1", "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} /> <UserHeader user={user} />
<CommentsFlat <CommentsFlat
comments={comments} cursor={cursor} comments={comments} cursor={cursor}
variables={{ name: user.name, sort: 'user' }} includeParent noReply variables={{ name: user.name }} includeParent noReply
/> />
</Layout> </Layout>
) )

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import Layout from '../components/layout' import Layout from '../components/layout'
import Items from '../components/items' import Items from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo' import { getGetServerSideProps } from '../api/ssrApollo'
import { MORE_ITEMS } from '../fragments/items' import { ITEMS } from '../fragments/items'
const variables = { sort: 'recent' } 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 ( return (
<Layout> <Layout>
<Items <Items

View File

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

View File

@ -2,13 +2,13 @@ import Layout from '../../../components/layout'
import Items from '../../../components/items' import Items from '../../../components/items'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../../api/ssrApollo' import { getGetServerSideProps } from '../../../api/ssrApollo'
import { MORE_ITEMS } from '../../../fragments/items' import { ITEMS } from '../../../fragments/items'
import TopHeader from '../../../components/top-header' 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() const router = useRouter()
return ( 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]) pin Pin? @relation(fields: [pinId], references: [id])
pinId Int? 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") User User[] @relation("Item")
@@index([createdAt]) @@index([createdAt])
@ -105,6 +118,30 @@ model Item {
@@index([parentId]) @@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 // the active pin is the latest one when it's a recurring cron
model Pin { model Pin {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())

View File

@ -63,6 +63,10 @@ $tooltip-bg: #5c8001;
color: var(--theme-grey) !important; color: var(--theme-grey) !important;
} }
ol, ul, dl {
padding-inline-start: 2rem;
}
mark { mark {
background-color: var(--primary); background-color: var(--primary);
padding: 0 0.2rem; padding: 0 0.2rem;
@ -364,6 +368,10 @@ textarea.form-control {
fill: #c03221; fill: #c03221;
} }
.fill-theme-color {
fill: var(--theme-color);
}
.text-underline { .text-underline {
text-decoration: 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 { user {
name name
} }
sub {
name
}
maxBid
upvotes upvotes
sats sats
boost boost