nostr sub

This commit is contained in:
keyan 2023-05-01 15:58:30 -05:00
parent d6c92fec62
commit a241d683d8
40 changed files with 837 additions and 249 deletions

View File

@ -146,6 +146,10 @@ function recentClause (type) {
}
}
const subClause = (sub, num) => {
return sub ? ` AND "subName" = $${num} ` : ` AND ("subName" IS NOT NULL OR "subName" = $${num}) `
}
export default {
Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => {
@ -194,10 +198,6 @@ export default {
const decodedCursor = decodeCursor(cursor)
let items; let user; let pins; let subFull
const subClause = (num) => {
return sub ? ` AND "subName" = $${num} ` : ` AND ("subName" IS NULL OR "subName" = $${num}) `
}
const activeOrMine = () => {
return me ? ` AND (status <> 'STOPPED' OR "userId" = ${me.id}) ` : ' AND status <> \'STOPPED\' '
}
@ -229,7 +229,7 @@ export default {
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
${subClause(3)}
${subClause(sub, 3)}
${activeOrMine()}
${await filterClause(me, models)}
${recentClause(type)}
@ -264,7 +264,7 @@ export default {
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
${subClause(sub, 3)}
AND status = 'ACTIVE' AND "maxBid" > 0
ORDER BY "maxBid" DESC, created_at ASC)
UNION ALL
@ -272,7 +272,7 @@ export default {
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
${subClause(sub, 3)}
AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
ORDER BY created_at DESC)
) a
@ -292,7 +292,7 @@ export default {
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3
AND "pinId" IS NULL AND NOT bio AND "deletedAt" IS NULL
${subClause(4)}
${subClause(sub, 4)}
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2
@ -305,7 +305,7 @@ export default {
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND NOT bio AND "deletedAt" IS NULL
${subClause(3)}
${subClause(sub, 3)}
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2
@ -323,7 +323,8 @@ export default {
)
FROM "Item"
WHERE "pinId" IS NOT NULL
) rank_filter WHERE RANK = 1`)
${subClause(sub, 1)}
) rank_filter WHERE RANK = 1`, sub)
}
break
}
@ -428,7 +429,7 @@ export default {
items
}
},
moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => {
moreFlatComments: async (parent, { sub, cursor, name, sort, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let comments, user
@ -437,11 +438,13 @@ export default {
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL AND created_at <= $1
JOIN "Item" root ON "Item"."rootId" = root.id
WHERE "Item"."parentId" IS NOT NULL AND "Item".created_at <= $1
AND (root."subName" = $3 OR (root."subName" IS NULL AND $3 IS NULL))
${await filterClause(me, models)}
ORDER BY created_at DESC
ORDER BY "Item".created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
break
case 'user':
if (!name) {
@ -467,13 +470,15 @@ export default {
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL AND "deletedAt" IS NULL
JOIN "Item" root ON "Item"."rootId" = root.id
WHERE "Item"."parentId" IS NOT NULL AND"Item"."deletedAt" IS NULL
AND (root."subName" = $3 OR (root."subName" IS NULL AND $3 IS NULL))
AND "Item".created_at <= $1
${topClause(within)}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
break
default:
throw new UserInputError('invalid sort type', { argumentName: 'sort' })
@ -664,7 +669,7 @@ export default {
}
},
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
const { forward, boost, title, text, options } = data
const { sub, forward, boost, title, text, options } = data
if (!me) {
throw new AuthenticationError('you must be logged in')
}
@ -693,16 +698,16 @@ export default {
throw new AuthenticationError('item does not belong to you')
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
await createMentions(item, models)
item.comments = []
return item
} else {
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
await createMentions(item, models)
item.comments = []
@ -1028,7 +1033,7 @@ export const createMentions = async (item, models) => {
}
}
export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, bounty, parentId } }, { me, models }) => {
export const updateItem = async (parent, { id, data: { sub, title, url, text, boost, forward, bounty, parentId } }, { me, models }) => {
// update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
@ -1059,15 +1064,16 @@ export const updateItem = async (parent, { id, data: { title, url, text, boost,
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7, $8) AS "Item"`,
old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text,
Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
await createMentions(item, models)
return item
}
const createItem = async (parent, { title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
@ -1091,7 +1097,8 @@ const createItem = async (parent, { title, url, text, boost, forward, bounty, pa
const [item] = await serialize(
models,
models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, $9, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
parentId ? null : sub || 'bitcoin',
title,
url,
text,

View File

@ -79,7 +79,7 @@ export default {
items
}
},
search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => {
search: async (parent, { q: query, cursor, sort, what, when }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems
@ -105,7 +105,9 @@ export default {
const queryArr = query.trim().split(/\s+/)
const url = queryArr.find(word => word.startsWith('url:'))
const nym = queryArr.find(word => word.startsWith('nym:'))
query = queryArr.filter(word => !word.startsWith('url:') && !word.startsWith('nym:')).join(' ')
const sub = queryArr.find(word => word.startsWith('~'))
const exclude = [url, nym, sub]
query = queryArr.filter(word => !exclude.includes(word)).join(' ')
if (url) {
whatArr.push({ wildcard: { url: `*${url.slice(4).toLowerCase()}*` } })
@ -115,6 +117,10 @@ export default {
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(4).toLowerCase()}*` } })
}
if (sub) {
whatArr.push({ match: { 'sub.name': sub.slice(1).toLowerCase() } })
}
const sortArr = []
switch (sort) {
case 'recent':
@ -204,9 +210,6 @@ export default {
bool: {
must: [
...whatArr,
sub
? { match: { 'sub.name': sub } }
: { bool: { must_not: { exists: { field: 'sub.name' } } } },
me
? {
bool: {

View File

@ -3,7 +3,7 @@ import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
moreFlatComments(sub: String, sort: String!, cursor: String, name: String, within: String): Comments
moreBookmarks(cursor: String, name: String!): Items
item(id: ID!): Item
comments(id: ID!, sort: String): [Item!]!
@ -12,7 +12,7 @@ export default gql`
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
allItems(cursor: String): Items
getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
search(q: String, cursor: String, what: String, sort: String, when: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int!
outlawedItems(cursor: String): Items
@ -35,12 +35,12 @@ export default gql`
extend type Mutation {
bookmarkItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item!
upsertBounty(id: ID, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item!
upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean,
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean!

View File

@ -2,12 +2,12 @@ import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
sub(name: ID!): Sub
subLatestPost(name: ID!): String
sub(name: String!): Sub
subLatestPost(name: String!): String
}
type Sub {
name: ID!
name: String!
createdAt: String!
updatedAt: String!
postTypes: [String!]!

View File

@ -10,6 +10,7 @@ import { bountySchema } from '../lib/validate'
export function BountyForm ({
item,
sub,
editThreshold,
titleLabel = 'title',
bountyLabel = 'bounty',
@ -24,6 +25,7 @@ export function BountyForm ({
const [upsertBounty] = useMutation(
gql`
mutation upsertBounty(
$sub: String
$id: ID
$title: String!
$bounty: Int!
@ -32,6 +34,7 @@ export function BountyForm ({
$forward: String
) {
upsertBounty(
sub: $sub
id: $id
title: $title
bounty: $bounty
@ -59,6 +62,7 @@ export function BountyForm ({
(async ({ boost, bounty, ...values }) => {
const { error } = await upsertBounty({
variables: {
sub: item?.sub?.name || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
@ -72,7 +76,8 @@ export function BountyForm ({
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push('/recent')
const prefix = sub?.name ? `/~${sub.name}/` : ''
await router.push(prefix + '/recent')
}
})
}

View File

@ -13,7 +13,7 @@ import { Button } from 'react-bootstrap'
import { discussionSchema } from '../lib/validate'
export function DiscussionForm ({
item, editThreshold, titleLabel = 'title',
item, sub, editThreshold, titleLabel = 'title',
textLabel = 'text', buttonText = 'post',
adv, handleSubmit
}) {
@ -23,8 +23,8 @@ export function DiscussionForm ({
// const me = useMe()
const [upsertDiscussion] = useMutation(
gql`
mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
upsertDiscussion(id: $id, title: $title, text: $text, boost: $boost, forward: $forward) {
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward) {
id
}
}`
@ -56,7 +56,7 @@ export function DiscussionForm ({
schema={schema}
onSubmit={handleSubmit || (async ({ boost, ...values }) => {
const { error } = await upsertDiscussion({
variables: { id: item?.id, boost: boost ? Number(boost) : undefined, ...values }
variables: { sub: item?.sub?.name || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values }
})
if (error) {
throw new Error({ message: error.toString() })
@ -65,7 +65,8 @@ export function DiscussionForm ({
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push('/recent')
const prefix = sub?.name ? `/~${sub.name}/` : ''
await router.push(prefix + '/recent')
}
})}
storageKeyPrefix={item ? undefined : 'discussion'}

View File

@ -1,5 +1,5 @@
import React from 'react'
import LayoutError from './layout-error'
import LayoutStatic from './layout-static'
import styles from '../styles/404.module.css'
class ErrorBoundary extends React.Component {
@ -25,10 +25,10 @@ class ErrorBoundary extends React.Component {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<LayoutError>
<LayoutStatic>
<Image width='500' height='375' src='/floating.gif' fluid />
<h1 className={styles.fourZeroFour} style={{ fontSize: '48px' }}>something went wrong</h1>
</LayoutError>
</LayoutStatic>
)
}

View File

@ -19,7 +19,7 @@ import { useEffect, useState } from 'react'
// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
const COLORS = {
light: {
body: '#f5f5f5',
body: '#f5f5f7',
color: '#212529',
navbarVariant: 'light',
navLink: 'rgba(0, 0, 0, 0.55)',
@ -157,9 +157,9 @@ export default function Footer ({ noLinks }) {
{!noLinks &&
<>
{mounted &&
<div className='mb-2'>
<DarkModeIcon onClick={() => darkMode.toggle()} className='fill-grey theme' />
<LnIcon onClick={toggleLightning} width={24} height={24} className='ml-2 fill-grey theme' />
<div className='mb-1'>
<DarkModeIcon onClick={() => darkMode.toggle()} width={20} height={20} className='fill-grey theme' />
<LnIcon onClick={toggleLightning} width={20} height={20} className='ml-2 fill-grey theme' />
</div>}
<div className='mb-0' style={{ fontWeight: 500 }}>
<Link href='/rewards' passHref>

View File

@ -16,6 +16,8 @@ import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
import CowboyHat from './cowboy-hat'
import { Form, Select } from './form'
import SearchIcon from '../svgs/search-line.svg'
function WalletSummary ({ me }) {
if (!me) return null
@ -25,34 +27,44 @@ function WalletSummary ({ me }) {
export default function Header ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
const [fired, setFired] = useState()
const [topNavKey, setTopNavKey] = useState('')
const [dropNavKey, setDropNavKey] = useState('')
const [prefix, setPrefix] = useState('')
const [path, setPath] = useState('')
const me = useMe()
const prefix = sub ? `/~${sub}` : ''
// there's always at least 2 on the split, e.g. '/' yields ['','']
const topNavKey = path.split('/')[sub ? 2 : 1]
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
const { data: subLatestPost } = useQuery(gql`
query subLatestPost($name: ID!) {
subLatestPost(name: $name)
}
`, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
useEffect(() => {
// there's always at least 2 on the split, e.g. '/' yields ['','']
const path = router.asPath.split('?')[0]
console.log(path, path.split('/')[sub ? 2 : 1], path.split('/').slice(sub ? 2 : 1).join('/'))
setPrefix(sub ? `/~${sub}` : '')
setTopNavKey(path.split('/')[sub ? 2 : 1] ?? '')
setDropNavKey(path.split('/').slice(sub ? 2 : 1).join('/'))
setPath(path)
}, [sub, router.asPath])
// const { data: subLatestPost } = useQuery(gql`
// query subLatestPost($name: ID!) {
// subLatestPost(name: $name)
// }
// `, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
const { data: hasNewNotes } = useQuery(gql`
{
hasNewNotes
}
`, { pollInterval: 30000, fetchPolicy: 'cache-and-network' })
const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
useEffect(() => {
if (me) {
setLastCheckedJobs(me.lastCheckedJobs)
} else {
if (sub === 'jobs') {
localStorage.setItem('lastCheckedJobs', new Date().getTime())
}
setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
}
}, [sub])
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
// useEffect(() => {
// if (me) {
// setLastCheckedJobs(me.lastCheckedJobs)
// } else {
// if (sub === 'jobs') {
// localStorage.setItem('lastCheckedJobs', new Date().getTime())
// }
// setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
// }
// }, [sub])
const Corner = () => {
if (me) {
@ -63,7 +75,7 @@ export default function Header ({ sub }) {
</Head>
<Link href='/notifications' passHref>
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
<NoteIcon className='theme' />
<NoteIcon height={22} width={22} className='theme' />
{hasNewNotes?.hasNewNotes &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
@ -73,11 +85,9 @@ export default function Header ({ sub }) {
<div className='position-relative'>
<NavDropdown
className={styles.dropdown} title={
<Link href={`/${me?.name}`} passHref>
<Nav.Link eventKey={me?.name} as='div' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
{`@${me?.name}`}<CowboyHat streak={me.streak} />
</Nav.Link>
</Link>
<Nav.Link eventKey={me?.name} as='span' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
{`@${me?.name}`}<CowboyHat streak={me.streak} />
</Nav.Link>
} alignRight
>
<Link href={'/' + me?.name} passHref>
@ -142,7 +152,7 @@ export default function Header ({ sub }) {
}, [])
}
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
<div>
<div className='ml-auto'>
<Button
className='align-items-center px-3 py-1 mr-2'
id='signup'
@ -168,12 +178,32 @@ export default function Header ({ sub }) {
}
}
const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
(!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
// const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
// (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
const NavItems = ({ className }) => {
return (
<>
<Nav.Item className={className}>
<Form
initial={{
sub: sub || 'home'
}}
>
<Select
groupClassName='mb-0'
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
name='sub'
size='sm'
items={['home', 'bitcoin', 'nostr', 'jobs']}
/>
</Form>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/'} passHref>
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref>
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
@ -185,7 +215,7 @@ export default function Header ({ sub }) {
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
<Nav.Item className={className}>
{/* <Nav.Item className={className}>
<div className='position-relative'>
<Link href='/~jobs' passHref>
<Nav.Link active={sub === 'jobs'} className={styles.navLink}>
@ -197,50 +227,65 @@ export default function Header ({ sub }) {
<span className='invisible'>{' '}</span>
</span>}
</div>
</Nav.Item>
{me &&
<Nav.Item className={className}>
<Link href={prefix + '/post'} passHref>
<Nav.Link eventKey='post' className={styles.navLinkButton}>post</Nav.Link>
</Link>
</Nav.Item>}
</Nav.Item> */}
{/* <Nav.Item className={`text-monospace nav-link mx-auto px-0 ${me?.name.length > 6 ? 'd-none d-lg-flex' : ''}`}>
<Price />
</Nav.Item> */}
</>
)
}
const PostItem = ({ className }) => {
return me
? (
<Nav.Link eventKey='post' className={`${className}`}>
<Link href={prefix + '/post'} passHref>
<button className='btn btn-md btn-primary px-3 py-1'>post</button>
</Link>
</Nav.Link>)
: null
}
return (
<>
<Container className='px-sm-0'>
<Navbar className='pb-0 pb-md-1'>
<Container className='px-0'>
<Navbar className='pb-0 pb-md-2'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<div className='d-flex'>
<div className='d-flex align-items-center'>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-none d-md-block`}>
STACKER NEWS
</Navbar.Brand>
</Link>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-block d-md-none`}>
<Navbar.Brand className={`${styles.brand} d-flex`}>
SN
</Navbar.Brand>
</Link>
</div>
<NavItems className='d-none d-md-flex' />
<Nav.Item className={`text-monospace nav-link px-0 ${me?.name.length > 6 ? 'd-none d-lg-flex' : ''}`}>
<Price />
<NavItems className='d-none d-md-flex mx-2' />
<PostItem className='d-none d-md-flex mx-2' />
<Link href='/search' passHref>
<Nav.Link eventKey='search' className='position-relative d-none d-md-flex align-items-center mx-2'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
<Nav.Item className={`${styles.price} mx-auto align-items-center ${me?.name.length > 10 ? 'd-none d-lg-flex' : ''}`}>
<Price className='nav-link text-monospace' />
</Nav.Item>
<Corner />
</Nav>
</Navbar>
<Navbar className='pt-0 pb-1 d-md-none'>
<Navbar className='pt-0 pb-2 d-md-none'>
<Nav
className={`${styles.navbarNav} justify-content-around`}
className={`${styles.navbarNav}`}
activeKey={topNavKey}
>
<NavItems />
<NavItems className='mr-1' />
<Link href='/search' passHref>
<Nav.Link eventKey='search' className='position-relative ml-auto d-flex mr-1'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
<PostItem className='mr-0 pr-0' />
</Nav>
</Navbar>
</Container>
@ -248,32 +293,6 @@ export default function Header ({ sub }) {
)
}
const NavItemsStatic = ({ className }) => {
return (
<>
<Nav.Item className={className}>
<Link href='/recent' passHref>
<Nav.Link className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link href='/top/posts/day' passHref>
<Nav.Link className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<div className='position-relative'>
<Link href='/~jobs' passHref>
<Nav.Link className={styles.navLink}>
jobs
</Nav.Link>
</Link>
</div>
</Nav.Item>
</>
)
}
export function HeaderStatic () {
return (
<Container className='px-sm-0'>
@ -281,29 +300,19 @@ export function HeaderStatic () {
<Nav
className={styles.navbarNav}
>
<div className='d-flex'>
<div className='d-flex align-items-center'>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-none d-md-block`}>
STACKER NEWS
</Navbar.Brand>
</Link>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-block d-md-none`}>
<Navbar.Brand className={`${styles.brand}`}>
SN
</Navbar.Brand>
</Link>
<Link href='/search' passHref>
<Nav.Link eventKey='search' className='position-relative d-flex align-items-center mx-2'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
</div>
<NavItemsStatic className='d-none d-md-flex' />
<Nav.Item className='text-monospace nav-link px-0'>
<Price />
</Nav.Item>
</Nav>
</Navbar>
<Navbar className='pt-0 pb-1 d-md-none'>
<Nav
className={`${styles.navbarNav} justify-content-around`}
>
<NavItemsStatic />
</Nav>
</Navbar>
</Container>

View File

@ -48,10 +48,13 @@
.navbarNav {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
height: 39px;
align-content: center;
}
.price {
margin-top: 2px;
}
.dropdown {

View File

@ -1,7 +1,7 @@
import Link from 'next/link'
import styles from './item.module.css'
import UpVote from './upvote'
import { useEffect, useRef, useState } from 'react'
import { useRef } from 'react'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
@ -19,15 +19,8 @@ export function SearchTitle ({ title }) {
}
export default function Item ({ item, rank, belowTitle, right, children }) {
const [wrap, setWrap] = useState(false)
const titleRef = useRef()
useEffect(() => {
setWrap(
Math.ceil(parseFloat(window.getComputedStyle(titleRef.current).lineHeight)) <
titleRef.current.clientHeight)
}, [])
return (
<>
{rank
@ -41,7 +34,7 @@ export default function Item ({ item, rank, belowTitle, right, children }) {
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}>
<div className={`${styles.main} flex-wrap`}>
<Link href={`/items/${item.id}`} passHref>
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
@ -58,7 +51,7 @@ export default function Item ({ item, rank, belowTitle, right, children }) {
<>
{/* eslint-disable-next-line */}
<a
className={`${styles.link} ${wrap ? styles.linkSmall : ''}`} target='_blank' href={item.url}
className={`${styles.link} py-half py-md-0`} target='_blank' href={item.url}
rel={item.sats + item.boost >= NOFOLLOW_LIMIT ? null : 'nofollow'}
>
{item.url.replace(/(^https?:|^)\/\//, '')}

View File

@ -1,9 +1,8 @@
import Footer from './footer'
import { HeaderStatic } from './header'
import styles from './layout-center.module.css'
import Search from './search'
export default function LayoutError ({ children, ...props }) {
export default function LayoutStatic ({ children, ...props }) {
return (
<div className={styles.page}>
<HeaderStatic />
@ -11,7 +10,6 @@ export default function LayoutError ({ children, ...props }) {
{children}
</div>
<Footer />
<Search />
</div>
)
}

View File

@ -7,7 +7,7 @@ import Search from './search'
export default function Layout ({
sub, noContain, noFooter, noFooterLinks,
containClassName, noSeo, children
containClassName, noSeo, children, search
}) {
return (
<>
@ -22,7 +22,7 @@ export default function Layout ({
</Container>
)}
{!noFooter && <Footer noLinks={noFooterLinks} />}
{!noContain && <Search sub={sub} />}
{!noContain && search && <Search sub={sub} />}
</LightningProvider>
</>
)

View File

@ -13,7 +13,7 @@ import { Button } from 'react-bootstrap'
import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg'
export function LinkForm ({ item, editThreshold }) {
export function LinkForm ({ item, sub, editThreshold }) {
const router = useRouter()
const client = useApolloClient()
const schema = linkSchema(client)
@ -66,8 +66,8 @@ export function LinkForm ({ item, editThreshold }) {
const [upsertLink] = useMutation(
gql`
mutation upsertLink($id: ID, $title: String!, $url: String!, $boost: Int, $forward: String) {
upsertLink(id: $id, title: $title, url: $url, boost: $boost, forward: $forward) {
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward) {
id
}
}`
@ -85,7 +85,7 @@ export function LinkForm ({ item, editThreshold }) {
schema={schema}
onSubmit={async ({ boost, title, ...values }) => {
const { error } = await upsertLink({
variables: { id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), ...values }
variables: { sub: item?.sub?.name || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), ...values }
})
if (error) {
throw new Error({ message: error.toString() })
@ -93,7 +93,8 @@ export function LinkForm ({ item, editThreshold }) {
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push('/recent')
const prefix = sub?.name ? `/~${sub.name}/` : ''
await router.push(prefix + '/recent')
}
}}
storageKeyPrefix={item ? undefined : 'link'}

View File

@ -10,16 +10,16 @@ import Delete from './delete'
import { Button } from 'react-bootstrap'
import { pollSchema } from '../lib/validate'
export function PollForm ({ item, editThreshold }) {
export function PollForm ({ item, sub, editThreshold }) {
const router = useRouter()
const client = useApolloClient()
const schema = pollSchema(client)
const [upsertPoll] = useMutation(
gql`
mutation upsertPoll($id: ID, $title: String!, $text: String,
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: String) {
upsertPoll(id: $id, title: $title, text: $text,
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward) {
id
}
@ -42,6 +42,7 @@ export function PollForm ({ item, editThreshold }) {
const { error } = await upsertPoll({
variables: {
id: item?.id,
sub: item?.sub?.name || sub?.name,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
options: optionsFiltered,
@ -54,7 +55,8 @@ export function PollForm ({ item, editThreshold }) {
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push('/recent')
const prefix = sub?.name ? `/~${sub.name}/` : ''
await router.push(prefix + '/recent')
}
}}
storageKeyPrefix={item ? undefined : 'poll'}

View File

@ -1,6 +1,5 @@
import React, { useContext, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { Button } from 'react-bootstrap'
import { fixedDecimal } from '../lib/format'
import { useMe } from './me'
import { PRICE } from '../fragments/price'
@ -32,7 +31,7 @@ export function PriceProvider ({ price, children }) {
)
}
export default function Price () {
export default function Price ({ className }) {
const [asSats, setAsSats] = useState(undefined)
useEffect(() => {
setAsSats(localStorage.getItem('asSats'))
@ -54,25 +53,27 @@ export default function Price () {
}
}
const compClassName = (className || '') + ' text-reset pointer'
if (asSats === 'yep') {
return (
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
<div className={compClassName} onClick={handleClick} variant='link'>
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
</Button>
</div>
)
}
if (asSats === '1btc') {
return (
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
<div className={compClassName} onClick={handleClick} variant='link'>
1sat=1sat
</Button>
</div>
)
}
return (
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
<div className={compClassName} onClick={handleClick} variant='link'>
{fiatSymbol + fixedDecimal(price, 0)}
</Button>
</div>
)
}

View File

@ -1,8 +1,14 @@
import { Form, Select } from './form'
import { useRouter } from 'next/router'
export default function RecentHeader ({ type }) {
export default function RecentHeader ({ type, sub }) {
const router = useRouter()
const prefix = sub?.name ? `/~${sub.name}` : ''
const items = ['posts', 'bounties', 'comments', 'links', 'discussions', 'polls']
if (!sub?.name) {
items.push('bios')
}
return (
<Form
@ -10,14 +16,14 @@ export default function RecentHeader ({ type }) {
type: router.query.type || type || 'posts'
}}
>
<div className='text-muted font-weight-bold mt-1 mb-3 d-flex justify-content-end align-items-center'>
<div className='text-muted font-weight-bold mt-0 mb-3 d-flex justify-content-end align-items-center'>
<Select
groupClassName='mb-0 ml-2'
className='w-auto'
name='type'
size='sm'
items={['posts', 'bounties', 'comments', 'links', 'discussions', 'polls', 'bios']}
onChange={(formik, e) => router.push(e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`)}
items={items}
onChange={(formik, e) => router.push(prefix + (e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`))}
/>
</div>
</Form>

View File

@ -49,7 +49,7 @@ export default function Search ({ sub }) {
}
const showSearch = atBottom || searching || router.query.q
const filter = router.query.q && !sub
const filter = sub !== 'jobs'
return (
<>
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
@ -100,7 +100,7 @@ export default function Search ({ sub }) {
<Input
name='q'
required
autoFocus={showSearch && !atBottom}
autoFocus
groupClassName='mr-3 mb-0 flex-grow-1'
className='flex-grow-1'
clear

View File

@ -11,6 +11,7 @@ export default function Share ({ item }) {
? (
<div className='ml-auto pointer d-flex align-items-center'>
<ShareIcon
width={20} height={20}
className='mx-2 fill-grey theme'
onClick={() => {
if (navigator.share) {
@ -29,7 +30,7 @@ export default function Share ({ item }) {
: (
<Dropdown alignRight className='ml-auto pointer d-flex align-items-center' as='span'>
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
<ShareIcon className='mx-2 fill-grey theme' />
<ShareIcon width={20} height={20} className='mx-2 fill-grey theme' />
</Dropdown.Toggle>
<Dropdown.Menu>

View File

@ -26,7 +26,7 @@ export default function Toc ({ text }) {
return (
<Dropdown alignRight className='d-flex align-items-center'>
<Dropdown.Toggle as={CustomToggle} id='dropdown-custom-components'>
<TocIcon className='mx-2 fill-grey theme' />
<TocIcon width={20} height={20} className='mx-2 fill-grey theme' />
</Dropdown.Toggle>
<Dropdown.Menu as={CustomMenu}>

View File

@ -42,8 +42,8 @@ export const COMMENT_FIELDS = gql`
export const MORE_FLAT_COMMENTS = gql`
${COMMENT_FIELDS}
query MoreFlatComments($sort: String!, $cursor: String, $name: String, $within: String) {
moreFlatComments(sort: $sort, cursor: $cursor, name: $name, within: $within) {
query MoreFlatComments($sub: String, $sort: String!, $cursor: String, $name: String, $within: String) {
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
cursor
comments {
...CommentFields

View File

@ -1,5 +1,6 @@
import { gql } from '@apollo/client'
import { ITEM_FIELDS } from './items'
import { COMMENT_FIELDS } from './comments'
export const SUB_FIELDS = gql`
fragment SubFields on Sub {
@ -12,7 +13,7 @@ export const SUB_FIELDS = gql`
export const SUB = gql`
${SUB_FIELDS}
query Sub($sub: ID!) {
query Sub($sub: String!) {
sub(name: $sub) {
...SubFields
}
@ -21,11 +22,11 @@ export const SUB = gql`
export const SUB_ITEMS = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubRecent($sub: String, $sort: String) {
query SubItems($sub: String!, $sort: String, $type: String) {
sub(name: $sub) {
...SubFields
}
items(sub: $sub, sort: $sort) {
items(sub: $sub, sort: $sort, type: $type) {
cursor
items {
...ItemFields
@ -42,11 +43,11 @@ export const SUB_ITEMS = gql`
export const SUB_SEARCH = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubSearch($sub: String, $q: String, $cursor: String) {
query SubSearch($sub: String!, $q: String, $cursor: String) {
sub(name: $sub) {
...SubFields
}
search(q: $q, sub: $sub, cursor: $cursor) {
search(q: $q, cursor: $cursor) {
cursor
items {
...ItemFields
@ -57,3 +58,21 @@ export const SUB_SEARCH = gql`
}
}
`
export const SUB_FLAT_COMMENTS = gql`
${SUB_FIELDS}
${COMMENT_FIELDS}
query SubFlatComments($sub: String!, $sort: String!, $cursor: String) {
sub(name: $sub) {
...SubFields
}
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor) {
cursor
comments {
...CommentFields
}
}
}
`

View File

@ -1,12 +1,12 @@
import { Image } from 'react-bootstrap'
import LayoutError from '../components/layout-error'
import LayoutStatic from '../components/layout-static'
import styles from '../styles/404.module.css'
export default function fourZeroFour () {
return (
<LayoutError>
<LayoutStatic>
<Image width='500' height='376' src='/maze.gif' fluid />
<h1 className={styles.fourZeroFour}><span>404</span><span className={styles.notFound}>page not found</span></h1>
</LayoutError>
</LayoutStatic>
)
}

View File

@ -1,12 +1,12 @@
import { Image } from 'react-bootstrap'
import LayoutError from '../components/layout-error'
import LayoutStatic from '../components/layout-static'
import styles from '../styles/404.module.css'
export default function fourZeroFour () {
return (
<LayoutError>
<LayoutStatic>
<Image width='500' height='375' src='/falling.gif' fluid />
<h1 className={styles.fourZeroFour}><span>500</span><span className={styles.notFound}>server error</span></h1>
</LayoutError>
</LayoutStatic>
)
}

View File

@ -1,14 +1,14 @@
import LayoutError from '../components/layout-error'
import LayoutStatic from '../components/layout-static'
import { Image } from 'react-bootstrap'
export default function Email () {
return (
<LayoutError>
<LayoutStatic>
<div className='p-4 text-center'>
<h1>Check your email</h1>
<h4 className='pb-4'>A sign in link has been sent to your email address</h4>
<Image width='500' height='376' src='/hello.gif' fluid />
</div>
</LayoutError>
</LayoutStatic>
)
}

View File

@ -1,6 +1,6 @@
import { providers, getSession } from 'next-auth/client'
import Link from 'next/link'
import LayoutCenter from '../components/layout-center'
import LayoutStatic from '../components/layout-static'
import Login from '../components/login'
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
@ -31,11 +31,11 @@ function LoginFooter ({ callbackUrl }) {
export default function LoginPage (props) {
return (
<LayoutCenter>
<LayoutStatic>
<Login
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
{...props}
/>
</LayoutCenter>
</LayoutStatic>
)
}

View File

@ -9,6 +9,8 @@ import { getGetServerSideProps } from '../api/ssrApollo'
import AccordianItem from '../components/accordian-item'
import { PollForm } from '../components/poll-form'
import { BountyForm } from '../components/bounty-form'
import { Form, Select } from '../components/form'
import { useEffect, useState } from 'react'
export const getServerSideProps = getGetServerSideProps()
@ -61,9 +63,44 @@ export function PostForm () {
}
}
export function SubSelect ({ children }) {
const router = useRouter()
const [sub, setSub] = useState(router.query.sub || 'bitcoin')
useEffect(() => {
setSub(router.query.sub || 'bitcoin')
}, [router.query?.sub])
return (
<div className='mb-3 d-flex justify-content-start'>
<Form
className='w-auto'
initial={{
sub
}}
>
<Select
groupClassName='mb-0'
onChange={(formik, e) =>
// todo move the form values to the other sub's post form
router.push({
pathname: `/~${e.target.value}/post`,
query: router.query?.type ? { type: router.query.type } : undefined
})}
name='sub'
size='sm'
items={router.query?.type ? ['bitcoin', 'nostr'] : ['bitcoin', 'nostr', 'jobs']}
/>
</Form>
{children}
</div>
)
}
export default function Post () {
return (
<LayoutCenter>
<SubSelect />
<PostForm />
</LayoutCenter>
)

View File

@ -11,7 +11,7 @@ export default function Index ({ data: { search: { items, cursor } } }) {
const router = useRouter()
return (
<Layout noSeo>
<Layout noSeo search>
<SeoSearch />
{router.query?.q &&
<SearchItems

View File

@ -1,6 +1,6 @@
import { providers, getSession } from 'next-auth/client'
import Link from 'next/link'
import LayoutCenter from '../components/layout-center'
import LayoutStatic from '../components/layout-static'
import Login from '../components/login'
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
@ -42,13 +42,13 @@ function SignUpFooter ({ callbackUrl }) {
export default function SignUp ({ ...props }) {
return (
<LayoutCenter>
<LayoutStatic>
<Login
Header={() => <SignUpHeader />}
Footer={() => <SignUpFooter callbackUrl={props.callbackUrl} />}
text='Sign up'
{...props}
/>
</LayoutCenter>
</LayoutStatic>
)
}

View File

@ -8,7 +8,7 @@ export const getServerSideProps = getGetServerSideProps(USER_SEARCH, { limit: 21
export default function Index ({ data: { searchUsers } }) {
return (
<Layout noSeo>
<Layout noSeo search>
<SeoSearch />
<UserList users={searchUsers} />
</Layout>

View File

@ -2,15 +2,84 @@ import { getGetServerSideProps } from '../../../api/ssrApollo'
import { SUB } from '../../../fragments/subs'
import LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form'
import Link from 'next/link'
import { Button } from 'react-bootstrap'
import AccordianItem from '../../../components/accordian-item'
import { useMe } from '../../../components/me'
import { useRouter } from 'next/router'
import { DiscussionForm } from '../../../components/discussion-form'
import { LinkForm } from '../../../components/link-form'
import { PollForm } from '../../../components/poll-form'
import { BountyForm } from '../../../components/bounty-form'
import { SubSelect } from '../../post'
export const getServerSideProps = getGetServerSideProps(SUB, null,
data => !data.sub)
// need to recent list items
export function PostForm ({ type, sub }) {
const me = useMe()
const prefix = sub?.name ? `/~${sub.name}` : ''
if (!type) {
return (
<div className='align-items-center'>
{me?.freePosts && me?.sats < 1
? <div className='text-center font-weight-bold mb-3 text-success'>{me.freePosts} free posts left</div>
: null}
<Link href={prefix + '/post?type=link'}>
<Button variant='secondary'>link</Button>
</Link>
<span className='mx-3 font-weight-bold text-muted'>or</span>
<Link href={prefix + '/post?type=discussion'}>
<Button variant='secondary'>discussion</Button>
</Link>
<div className='d-flex mt-3'>
<AccordianItem
headerColor='#6c757d'
header={<div className='font-weight-bold text-muted'>more</div>}
body={
<div className='align-items-center'>
<Link href={prefix + '/post?type=poll'}>
<Button variant='info'>poll</Button>
</Link>
<span className='mx-3 font-weight-bold text-muted'>or</span>
<Link href={prefix + '/post?type=bounty'}>
<Button variant='info'>bounty</Button>
</Link>
</div>
}
/>
</div>
</div>
)
}
if (type === 'discussion') {
return <DiscussionForm sub={sub} />
} else if (type === 'link') {
return <LinkForm sub={sub} />
} else if (type === 'poll') {
return <PollForm sub={sub} />
} else if (type === 'bounty') {
return <BountyForm sub={sub} />
} else {
return <JobForm sub={sub} />
}
}
export default function Post ({ data: { sub } }) {
const router = useRouter()
let type = router.query.type
if (sub.postTypes.length === 1) {
type = sub.postTypes[0].toLowerCase()
}
return (
<LayoutCenter sub={sub.name}>
<JobForm sub={sub} />
{sub.name !== 'jobs' && <SubSelect />}
<PostForm type={type} sub={sub} />
</LayoutCenter>
)
}

View File

@ -1,20 +0,0 @@
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,
data => !data.sub)
// 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>
)
}

View File

@ -0,0 +1,22 @@
import Layout from '../../../../components/layout'
import Items from '../../../../components/items'
import { getGetServerSideProps } from '../../../../api/ssrApollo'
import RecentHeader from '../../../../components/recent-header'
import { useRouter } from 'next/router'
import { SUB_ITEMS } from '../../../../fragments/subs'
const variables = { sort: 'recent' }
export const getServerSideProps = getGetServerSideProps(SUB_ITEMS, variables, data => !data.sub)
export default function Index ({ data: { sub, items: { items, cursor } } }) {
const router = useRouter()
return (
<Layout sub={sub?.name}>
<RecentHeader sub={sub} />
<Items
items={items} cursor={cursor}
variables={{ ...variables, sub: sub?.name, type: router.query.type }} rank
/>
</Layout>
)
}

View File

@ -0,0 +1,20 @@
import Layout from '../../../../components/layout'
import { getGetServerSideProps } from '../../../../api/ssrApollo'
import CommentsFlat from '../../../../components/comments-flat'
import RecentHeader from '../../../../components/recent-header'
import { SUB_FLAT_COMMENTS } from '../../../../fragments/subs'
const variables = { sort: 'recent' }
export const getServerSideProps = getGetServerSideProps(SUB_FLAT_COMMENTS, variables)
export default function Index ({ data: { sub, moreFlatComments: { comments, cursor } } }) {
return (
<Layout>
<RecentHeader type='comments' sub={sub} />
<CommentsFlat
comments={comments} cursor={cursor}
variables={{ sort: 'recent', sub: sub?.name }} includeParent noReply
/>
</Layout>
)
}

View File

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

View File

@ -0,0 +1,210 @@
-- AlterEnum
ALTER TYPE "PostType" ADD VALUE 'BOUNTY';
DROP FUNCTION IF EXISTS create_item(
title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER,
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
spam_within INTERVAL);
CREATE OR REPLACE FUNCTION create_item(
sub TEXT, title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER,
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT;
free_posts INTEGER;
free_comments INTEGER;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, "freePosts", "freeComments"
INTO user_msats, free_posts, free_comments
FROM users WHERE id = user_id;
cost_msats := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within));
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, boost = 0, and they have freebies left
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (boost = 0) AND ((parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0));
IF NOT freebie AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- get this user's median item score
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id;
-- if their median votes are positive, start at 0
-- if the median votes are negative, start their post with that many down votes
-- basically: if their median post is bad, presume this post is too
IF med_votes >= 0 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
INSERT INTO "Item"
("subName", title, url, text, bounty, "userId", "parentId", "fwdUserId",
freebie, "weightedDownVotes", created_at, updated_at)
VALUES
(sub, title, url, text, bounty, user_id, parent_id, fwd_user_id,
freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item;
IF freebie THEN
IF parent_id IS NULL THEN
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
ELSE
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
END IF;
ELSE
UPDATE users SET msats = msats - cost_msats WHERE id = user_id;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (cost_msats, item.id, user_id, 'FEE', now_utc(), now_utc());
END IF;
IF boost > 0 THEN
PERFORM item_act(item.id, user_id, 'BOOST', boost);
END IF;
RETURN item;
END;
$$;
DROP FUNCTION IF EXISTS update_item(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,item_bounty INTEGER,
fwd_user_id INTEGER);
CREATE OR REPLACE FUNCTION update_item(
sub TEXT, item_id INTEGER, item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
item_bounty INTEGER, fwd_user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
UPDATE "Item"
SET "subName" = sub, title = item_title, url = item_url,
text = item_text, bounty = item_bounty, "fwdUserId" = fwd_user_id
WHERE id = item_id
RETURNING * INTO item;
IF boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', boost);
END IF;
RETURN item;
END;
$$;
DROP FUNCTION IF EXISTS create_poll(
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL);
CREATE OR REPLACE FUNCTION create_poll(
sub TEXT, title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(sub, title, null, text, boost, null, user_id, fwd_user_id, spam_within);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
DROP FUNCTION IF EXISTS update_poll(
id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN);
CREATE OR REPLACE FUNCTION update_poll(
sub TEXT, id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := update_item(sub, id, title, null, text, boost, fwd_user_id);
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION update_job(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, job_bid INTEGER, job_company TEXT,
job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER, job_status "Status")
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
-- update item
SELECT * INTO item FROM update_item('jobs', item_id, item_title, item_url, item_text, 0, 0, NULL);
IF item.status <> job_status THEN
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, status = job_status, "statusUpdatedAt" = now_utc()
WHERE id = item.id RETURNING * INTO item;
ELSE
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id
WHERE id = item.id RETURNING * INTO item;
END IF;
-- run_auction
EXECUTE run_auction(item.id);
RETURN item;
END;
$$;
-- when creating bio, set bio flag so they won't appear on first page
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM create_item(NULL, title, NULL, text, 0, 0, NULL, user_id, NULL, '0');
UPDATE "Item" SET bio = true WHERE id = item.id;
UPDATE users SET "bioId" = item.id WHERE id = user_id;
RETURN item;
END;
$$;

View File

@ -0,0 +1,102 @@
DROP FUNCTION IF EXISTS create_poll(
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL);
CREATE OR REPLACE FUNCTION create_poll(
sub TEXT, title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(sub, title, null, text, boost, null, null, user_id, fwd_user_id, spam_within);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
DROP FUNCTION IF EXISTS update_poll(
id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN);
CREATE OR REPLACE FUNCTION update_poll(
sub TEXT, id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := update_item(sub, id, title, null, text, boost, null, fwd_user_id);
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
-- when creating bio, set bio flag so they won't appear on first page
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM create_item(NULL, title, NULL, text, 0, NULL, NULL, user_id, NULL, '0');
UPDATE "Item" SET bio = true WHERE id = item.id;
UPDATE users SET "bioId" = item.id WHERE id = user_id;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION update_job(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, job_bid INTEGER, job_company TEXT,
job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER, job_status "Status")
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
-- update item
SELECT * INTO item FROM update_item('jobs', item_id, item_title, item_url, item_text, 0, NULL, NULL);
IF item.status <> job_status THEN
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, status = job_status, "statusUpdatedAt" = now_utc()
WHERE id = item.id RETURNING * INTO item;
ELSE
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id
WHERE id = item.id RETURNING * INTO item;
END IF;
-- run_auction
EXECUTE run_auction(item.id);
RETURN item;
END;
$$;

View File

@ -0,0 +1,40 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "subs" TEXT[];
-- CreateTable
CREATE TABLE "Subscription" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subName" CITEXT NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Subscription" ADD FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- create the nostr sub ignoring conflicts
INSERT INTO "Sub" ("name", "desc", "postTypes", "rankingType")
VALUES ('nostr', 'everything nostr related', '{LINK,DISCUSSION,POLL,BOUNTY}', 'WOT') ON CONFLICT DO NOTHING;
-- create bitcoin sub ignoring conflicts
INSERT INTO "Sub" ("name", "desc", "postTypes", "rankingType")
VALUES ('bitcoin', 'everything bitcoin related', '{LINK,DISCUSSION,POLL,BOUNTY}', 'WOT') ON CONFLICT DO NOTHING;
-- all root items with null subName put in bitcoin sub ... unless title has nostr in it
UPDATE "Item"
SET "subName" = 'bitcoin'
WHERE "Item"."subName" IS NULL
AND "Item"."parentId" IS NULL
AND "Item".title NOT ILIKE '%nostr%';
UPDATE "Item"
SET "subName" = 'nostr'
WHERE "Item"."subName" IS NULL
AND "Item"."parentId" IS NULL
AND "Item".title ILIKE '%nostr%';

View File

@ -44,6 +44,7 @@ model User {
lastCheckedJobs DateTime?
photoId Int?
photo Upload? @relation(fields: [photoId], references: [id])
subs String[]
// streak
streak Int?
@ -83,13 +84,14 @@ model User {
wildWestMode Boolean @default(false)
greeterMode Boolean @default(false)
Earn Earn[]
Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[]
Donation Donation[]
ReferralAct ReferralAct[]
Streak Streak[]
Bookmarks Bookmark[]
Earn Earn[]
Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[]
Donation Donation[]
ReferralAct ReferralAct[]
Streak Streak[]
Bookmarks Bookmark[]
Subscriptions Subscription[]
@@index([createdAt])
@@index([inviteId])
@ -350,6 +352,7 @@ enum PostType {
DISCUSSION
JOB
POLL
BOUNTY
}
enum RankingType {
@ -367,7 +370,18 @@ model Sub {
baseCost Int @default(1)
desc String?
Item Item[]
Item Item[]
Subscription Subscription[]
}
model Subscription {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
subName String @db.Citext
sub Sub @relation(fields: [subName], references: [name])
user User @relation(fields: [userId], references: [id])
userId Int
}
// the active pin is the latest one when it's a recurring cron

View File

@ -145,7 +145,7 @@ body {
background: var(--theme-body);
color: var(--theme-color);
min-height: 100vh;
height: 100%;
min-height: 100svh;
}
a {
@ -172,6 +172,24 @@ div[contenteditable],
border-color: var(--theme-borderColor);
}
select.custom-select {
background-color: var(--theme-clickToContextColor);
color: var(--theme-dropdownItemColor);
font-weight: bold;
border: none;
background-image: url("data:image/svg+xml, %3Csvg fill='%23808080' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.0006L7.75732 10.758L9.17154 9.34375L12 12.1722L14.8284 9.34375L16.2426 10.758L12 15.0006Z'%3E%3C/path%3E%3C/svg%3E%0A");
background-repeat: no-repeat;
background-position: right .25rem center;
background-size: 1.5rem;
}
select.custom-select:focus {
border-color: none !important;
box-shadow: none !important;
}
div[contenteditable]:focus,
.form-control:focus {
background-color: var(--theme-inputBg);
@ -194,6 +212,11 @@ div[contenteditable]:disabled,
cursor: pointer;
}
.py-half {
padding-top: .125rem;
padding-bottom: .125rem;
}
.clickToContext:hover {
background-color: var(--theme-clickToContextColor);
}
@ -273,8 +296,8 @@ div[contenteditable]:disabled,
#__next {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100vh;
min-height: 100svh;
}
footer {