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 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]
|
||||||
|
@ -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
11
api/resolvers/sub.js
Normal file
@ -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 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]
|
||||||
|
@ -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
16
api/typeDefs/sub.js
Normal 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!
|
||||||
|
}
|
||||||
|
`
|
@ -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
|
||||||
|
@ -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> </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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
@ -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
183
components/job-form.js
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -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}`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -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) {
|
||||||
|
@ -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
54
fragments/subs.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
@ -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
|
||||||
|
@ -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
7
lib/format.js
Normal 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'
|
||||||
|
}
|
@ -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
13229
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export default function Sub () {
|
|
||||||
return <h1>hi</h1>
|
|
||||||
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
17
pages/~/[sub]/index.js
Normal 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
15
pages/~/[sub]/post.js
Normal 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
19
pages/~/[sub]/recent.js
Normal 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
21
pages/~/[sub]/search.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
35
prisma/migrations/20220214215140_subs/migration.sql
Normal file
35
prisma/migrations/20220214215140_subs/migration.sql
Normal 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;
|
4
prisma/migrations/20220218193307_sub_desc/migration.sql
Normal file
4
prisma/migrations/20220218193307_sub_desc/migration.sql
Normal 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';
|
@ -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())
|
||||||
|
@ -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
13
styles/post.module.css
Normal 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;
|
||||||
|
}
|
1
svgs/briefcase-4-fill.svg
Normal file
1
svgs/briefcase-4-fill.svg
Normal 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 |
1
svgs/information-fill.svg
Normal file
1
svgs/information-fill.svg
Normal 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 |
@ -14,6 +14,10 @@ const ITEM_SEARCH_FIELDS = gql`
|
|||||||
user {
|
user {
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
sub {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
maxBid
|
||||||
upvotes
|
upvotes
|
||||||
sats
|
sats
|
||||||
boost
|
boost
|
||||||
|
Loading…
x
Reference in New Issue
Block a user