jobs w/o payments yet
This commit is contained in:
parent
155307127c
commit
b954186d31
|
@ -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]
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export default {
|
||||
Query: {
|
||||
sub: async (parent, { name }, { models }) => {
|
||||
return await models.sub.findUnique({
|
||||
where: {
|
||||
name
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
`
|
|
@ -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
|
||||
|
|
|
@ -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> </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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
}
|
|
@ -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*'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default function Sub () {
|
||||
return <h1>hi</h1>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
|
@ -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';
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 |
|
@ -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 |
|
@ -14,6 +14,10 @@ const ITEM_SEARCH_FIELDS = gql`
|
|||
user {
|
||||
name
|
||||
}
|
||||
sub {
|
||||
name
|
||||
}
|
||||
maxBid
|
||||
upvotes
|
||||
sats
|
||||
boost
|
||||
|
|
Loading…
Reference in New Issue