add polls
This commit is contained in:
parent
5ac894baed
commit
82280b0966
|
@ -34,7 +34,7 @@ export default {
|
|||
return await models.$queryRaw(
|
||||
`SELECT date_trunc('month', "ItemAct".created_at) AS time,
|
||||
sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END) as jobs,
|
||||
sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees,
|
||||
sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees,
|
||||
sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END) as boost,
|
||||
sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips
|
||||
FROM "ItemAct"
|
||||
|
@ -122,7 +122,7 @@ export default {
|
|||
const [stats] = await models.$queryRaw(
|
||||
`SELECT json_build_array(
|
||||
json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)),
|
||||
json_build_object('name', 'fees', 'value', sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)),
|
||||
json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)),
|
||||
json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END)),
|
||||
json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END))) as array
|
||||
FROM "ItemAct"
|
||||
|
|
|
@ -458,6 +458,50 @@ export default {
|
|||
return await createItem(parent, data, { me, models })
|
||||
}
|
||||
},
|
||||
upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
if (boost && boost < BOOST_MIN) {
|
||||
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// TODO: this isn't ever called clientside, we edit like it's a discussion
|
||||
|
||||
const item = await models.item.update({
|
||||
where: { id: Number(id) },
|
||||
data: { title: title }
|
||||
})
|
||||
|
||||
return item
|
||||
} else {
|
||||
let fwdUser
|
||||
if (forward) {
|
||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||
if (!fwdUser) {
|
||||
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
|
||||
}
|
||||
}
|
||||
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6) AS "Item"`,
|
||||
title, text, 1, Number(boost || 0), Number(me.id), options))
|
||||
|
||||
if (fwdUser) {
|
||||
await models.item.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
fwdUserId: fwdUser.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
item.comments = []
|
||||
return item
|
||||
}
|
||||
},
|
||||
upsertJob: async (parent, {
|
||||
id, sub, title, company, location, remote,
|
||||
text, url, maxBid, status, logo
|
||||
|
@ -534,6 +578,17 @@ export default {
|
|||
updateComment: async (parent, { id, text }, { me, models }) => {
|
||||
return await updateItem(parent, { id, data: { text } }, { me, models })
|
||||
},
|
||||
pollVote: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
await serialize(models,
|
||||
models.$queryRaw(`${SELECT} FROM poll_vote($1, $2) AS "Item"`,
|
||||
Number(id), Number(me.id)))
|
||||
|
||||
return id
|
||||
},
|
||||
act: async (parent, { id, sats }, { me, models }) => {
|
||||
// need to make sure we are logged in
|
||||
if (!me) {
|
||||
|
@ -561,7 +616,6 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
Item: {
|
||||
sub: async (item, args, { models }) => {
|
||||
if (!item.subName) {
|
||||
|
@ -605,6 +659,27 @@ export default {
|
|||
|
||||
return prior.id
|
||||
},
|
||||
poll: async (item, args, { models, me }) => {
|
||||
if (!item.pollCost) {
|
||||
return null
|
||||
}
|
||||
|
||||
const options = await models.$queryRaw`
|
||||
SELECT "PollOption".id, option, count("PollVote"."userId") as count,
|
||||
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
|
||||
FROM "PollOption"
|
||||
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
|
||||
WHERE "PollOption"."itemId" = ${item.id}
|
||||
GROUP BY "PollOption".id
|
||||
ORDER BY "PollOption".id ASC
|
||||
`
|
||||
const poll = {}
|
||||
poll.options = options
|
||||
poll.meVoted = options.some(o => o.meVoted)
|
||||
poll.count = options.reduce((t, o) => t + o.count, 0)
|
||||
|
||||
return poll
|
||||
},
|
||||
user: async (item, args, { models }) =>
|
||||
await models.user.findUnique({ where: { id: item.userId } }),
|
||||
fwdUser: async (item, args, { models }) => {
|
||||
|
@ -852,7 +927,7 @@ export const SELECT =
|
|||
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
|
||||
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
||||
"Item".company, "Item".location, "Item".remote,
|
||||
"Item"."subName", "Item".status, "Item"."uploadId", ltree2text("Item"."path") AS "path"`
|
||||
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", ltree2text("Item"."path") AS "path"`
|
||||
|
||||
function newTimedOrderByWeightedSats (num) {
|
||||
return `
|
||||
|
|
|
@ -28,7 +28,7 @@ export default {
|
|||
}
|
||||
})
|
||||
|
||||
return latest.createdAt
|
||||
return latest?.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,9 +23,24 @@ export default gql`
|
|||
upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item!
|
||||
upsertJob(id: ID, sub: ID!, 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!
|
||||
createComment(text: String!, parentId: ID!): Item!
|
||||
updateComment(id: ID!, text: String!): Item!
|
||||
act(id: ID!, sats: Int): ItemActResult!
|
||||
pollVote(id: ID!): ID!
|
||||
}
|
||||
|
||||
type PollOption {
|
||||
id: ID,
|
||||
option: String!
|
||||
count: Int!
|
||||
meVoted: Boolean!
|
||||
}
|
||||
|
||||
type Poll {
|
||||
meVoted: Boolean!
|
||||
count: Int!
|
||||
options: [PollOption!]!
|
||||
}
|
||||
|
||||
type Items {
|
||||
|
@ -67,6 +82,8 @@ export default gql`
|
|||
position: Int
|
||||
prior: Int
|
||||
maxBid: Int
|
||||
pollCost: Int
|
||||
poll: Poll
|
||||
company: String
|
||||
location: String
|
||||
remote: Boolean
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useFormikContext } from 'formik'
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||
|
||||
export default function ActionTooltip ({ children, notForm, disable, overlayText }) {
|
||||
export default function ActionTooltip ({ children, notForm, disable, overlayText, placement }) {
|
||||
// if we're in a form, we want to hide tooltip on submit
|
||||
let formik
|
||||
if (!notForm) {
|
||||
|
@ -12,7 +12,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
|
|||
}
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement='bottom'
|
||||
placement={placement || 'bottom'}
|
||||
overlay={
|
||||
<Tooltip>
|
||||
{overlayText || '1 sat'}
|
||||
|
|
|
@ -2,14 +2,15 @@ import Button from 'react-bootstrap/Button'
|
|||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import BootstrapForm from 'react-bootstrap/Form'
|
||||
import Alert from 'react-bootstrap/Alert'
|
||||
import { Formik, Form as FormikForm, useFormikContext, useField } from 'formik'
|
||||
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import copy from 'clipboard-copy'
|
||||
import Thumb from '../svgs/thumb-up-fill.svg'
|
||||
import { Nav } from 'react-bootstrap'
|
||||
import { Col, Nav } from 'react-bootstrap'
|
||||
import Markdown from '../svgs/markdown-line.svg'
|
||||
import styles from './form.module.css'
|
||||
import Text from '../components/text'
|
||||
import AddIcon from '../svgs/add-fill.svg'
|
||||
|
||||
export function SubmitButton ({
|
||||
children, variant, value, onClick, ...props
|
||||
|
@ -201,6 +202,39 @@ export function Input ({ label, groupClassName, ...props }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function VariableInput ({ label, groupClassName, name, hint, max, ...props }) {
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<FieldArray name={name}>
|
||||
{({ form, ...fieldArrayHelpers }) => {
|
||||
const options = form.values.options
|
||||
return (
|
||||
<>
|
||||
{options.map((_, i) => (
|
||||
<div key={i}>
|
||||
<BootstrapForm.Row className='mb-2'>
|
||||
<Col>
|
||||
<InputInner name={`${name}[${i}]`} {...props} placeholder={i > 1 ? 'optional' : undefined} />
|
||||
</Col>
|
||||
{options.length - 1 === i && options.length !== max
|
||||
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />
|
||||
: null}
|
||||
</BootstrapForm.Row>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</FieldArray>
|
||||
{hint && (
|
||||
<BootstrapForm.Text>
|
||||
{hint}
|
||||
</BootstrapForm.Text>
|
||||
)}
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) {
|
||||
// React treats radios and checkbox inputs differently other input types, select, and textarea.
|
||||
// Formik does this too! When you specify `type` to useField(), it will
|
||||
|
@ -243,11 +277,17 @@ export function Form ({
|
|||
validationSchema={schema}
|
||||
initialTouched={validateImmediately && initial}
|
||||
validateOnBlur={false}
|
||||
onSubmit={async (...args) =>
|
||||
onSubmit && onSubmit(...args).then(() => {
|
||||
onSubmit={async (values, ...args) =>
|
||||
onSubmit && onSubmit(values, ...args).then(() => {
|
||||
if (!storageKeyPrefix) return
|
||||
Object.keys(...args).forEach(v =>
|
||||
localStorage.removeItem(storageKeyPrefix + '-' + v))
|
||||
Object.keys(values).forEach(v => {
|
||||
localStorage.removeItem(storageKeyPrefix + '-' + v)
|
||||
if (Array.isArray(values[v])) {
|
||||
values[v].forEach(
|
||||
(_, i) => localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`))
|
||||
}
|
||||
}
|
||||
)
|
||||
}).catch(e => setError(e.message || e))}
|
||||
>
|
||||
<FormikForm {...props} noValidate>
|
||||
|
|
|
@ -12,6 +12,7 @@ import { TwitterTweetEmbed } from 'react-twitter-embed'
|
|||
import YouTube from 'react-youtube'
|
||||
import useDarkMode from 'use-dark-mode'
|
||||
import { useState } from 'react'
|
||||
import Poll from './poll'
|
||||
|
||||
function BioItem ({ item, handleClick }) {
|
||||
const me = useMe()
|
||||
|
@ -87,6 +88,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
|||
<ItemComponent item={item} toc showFwdUser {...props}>
|
||||
{item.text && <ItemText item={item} />}
|
||||
{item.url && <ItemEmbed item={item} />}
|
||||
{item.poll && <Poll item={item} />}
|
||||
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}
|
||||
</ItemComponent>
|
||||
)
|
||||
|
|
|
@ -8,6 +8,7 @@ import { NOFOLLOW_LIMIT } from '../lib/constants'
|
|||
import Pin from '../svgs/pushpin-fill.svg'
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import Toc from './table-of-contents'
|
||||
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
|
||||
|
||||
export function SearchTitle ({ title }) {
|
||||
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
||||
|
@ -55,6 +56,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
||||
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
||||
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>}
|
||||
</a>
|
||||
</Link>
|
||||
{item.url &&
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../components/form'
|
||||
import { useRouter } from 'next/router'
|
||||
import * as Yup from 'yup'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import ActionTooltip from '../components/action-tooltip'
|
||||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
|
||||
import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH } from '../lib/constants'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
export function PollForm ({ item, editThreshold }) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
|
||||
const [upsertPoll] = useMutation(
|
||||
gql`
|
||||
mutation upsertPoll($id: ID, $title: String!, $text: String,
|
||||
$options: [String!]!, $boost: Int, $forward: String) {
|
||||
upsertPoll(id: $id, title: $title, text: $text,
|
||||
options: $options, boost: $boost, forward: $forward) {
|
||||
id
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
const PollSchema = Yup.object({
|
||||
title: Yup.string().required('required').trim()
|
||||
.max(MAX_TITLE_LENGTH,
|
||||
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
||||
options: Yup.array().of(
|
||||
Yup.string().trim().test('my-test', 'required', function (value) {
|
||||
return (this.path !== 'options[0]' && this.path !== 'options[1]') || value
|
||||
}).max(MAX_POLL_CHOICE_LENGTH,
|
||||
({ max, value }) => `${Math.abs(max - value.length)} too many`)
|
||||
),
|
||||
...AdvPostSchema(client)
|
||||
})
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: item?.title || '',
|
||||
options: item?.options || ['', ''],
|
||||
...AdvPostInitial
|
||||
}}
|
||||
schema={PollSchema}
|
||||
onSubmit={async ({ boost, title, options, ...values }) => {
|
||||
const optionsFiltered = options.filter(word => word.trim().length > 0)
|
||||
const { error } = await upsertPoll({
|
||||
variables: {
|
||||
id: item?.id,
|
||||
boost: Number(boost),
|
||||
title: title.trim(),
|
||||
options: optionsFiltered,
|
||||
...values
|
||||
}
|
||||
})
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
if (item) {
|
||||
await router.push(`/items/${item.id}`)
|
||||
} else {
|
||||
await router.push('/recent')
|
||||
}
|
||||
}}
|
||||
storageKeyPrefix={item ? undefined : 'poll'}
|
||||
>
|
||||
<Input
|
||||
label='title'
|
||||
name='title'
|
||||
required
|
||||
/>
|
||||
<MarkdownInput
|
||||
topLevel
|
||||
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={2}
|
||||
/>
|
||||
<VariableInput
|
||||
label='choices'
|
||||
name='options'
|
||||
max={5}
|
||||
hint={editThreshold
|
||||
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
/>
|
||||
{!item && <AdvPostForm />}
|
||||
<ActionTooltip>
|
||||
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
|
||||
</ActionTooltip>
|
||||
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { fixedDecimal } from '../lib/format'
|
||||
import { timeLeft } from '../lib/time'
|
||||
import { useMe } from './me'
|
||||
import styles from './poll.module.css'
|
||||
import Check from '../svgs/checkbox-circle-fill.svg'
|
||||
import { signIn } from 'next-auth/client'
|
||||
import { useFundError } from './fund-error'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
|
||||
export default function Poll ({ item }) {
|
||||
const me = useMe()
|
||||
const { setError } = useFundError()
|
||||
const [pollVote] = useMutation(
|
||||
gql`
|
||||
mutation pollVote($id: ID!) {
|
||||
pollVote(id: $id)
|
||||
}`, {
|
||||
update (cache, { data: { pollVote } }) {
|
||||
cache.modify({
|
||||
id: `Item:${item.id}`,
|
||||
fields: {
|
||||
poll (existingPoll) {
|
||||
const poll = { ...existingPoll }
|
||||
poll.meVoted = true
|
||||
poll.count += 1
|
||||
return poll
|
||||
}
|
||||
}
|
||||
})
|
||||
cache.modify({
|
||||
id: `PollOption:${pollVote}`,
|
||||
fields: {
|
||||
count (existingCount) {
|
||||
return existingCount + 1
|
||||
},
|
||||
meVoted () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const PollButton = ({ v }) => {
|
||||
return (
|
||||
<ActionTooltip placement='left' notForm>
|
||||
<Button
|
||||
variant='outline-info' className={styles.pollButton}
|
||||
onClick={me
|
||||
? async () => {
|
||||
try {
|
||||
await pollVote({
|
||||
variables: { id: v.id },
|
||||
optimisticResponse: {
|
||||
pollVote: v.id
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
: signIn}
|
||||
>
|
||||
{v.option}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5))
|
||||
const mine = item.user.id === me?.id
|
||||
return (
|
||||
<div className={styles.pollBox}>
|
||||
{item.poll.options.map(v =>
|
||||
expiresIn && !item.poll.meVoted && !mine
|
||||
? <PollButton key={v.id} v={v} />
|
||||
: <PollResult
|
||||
key={v.id} v={v}
|
||||
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
|
||||
/>)}
|
||||
<div className='text-muted mt-1'>{item.poll.count} votes \ {expiresIn ? `${expiresIn} left` : 'poll ended'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PollResult ({ v, progress }) {
|
||||
return (
|
||||
<div className={styles.pollResult}>
|
||||
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ml-1 align-self-center' width={18} height={18} />}</span>
|
||||
<span className='ml-auto mr-2 align-self-center'>{progress}%</span>
|
||||
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
.pollButton {
|
||||
margin-top: .25rem;
|
||||
display: block;
|
||||
border: 2px solid var(--info);
|
||||
border-radius: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 0rem 1.1rem;
|
||||
height: 2rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pollBox {
|
||||
padding-top: .5rem;
|
||||
padding-right: 15px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.pollResult {
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 2rem;
|
||||
margin-top: .25rem;
|
||||
display: flex;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
|
||||
.pollProgress {
|
||||
content: '\A';
|
||||
border-radius: .4rem 0rem 0rem .4rem;
|
||||
position: absolute;
|
||||
background: var(--theme-clickToContextColor);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.pollResult .pollOption {
|
||||
align-self: center;
|
||||
margin-left: .5rem;
|
||||
display: flex;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useContext, useEffect, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import useSWR from 'swr'
|
||||
import { fixedDecimal } from '../lib/format'
|
||||
|
||||
const fetcher = url => fetch(url).then(res => res.json()).catch()
|
||||
|
||||
|
@ -49,7 +50,6 @@ export default function Price () {
|
|||
|
||||
if (!price) return null
|
||||
|
||||
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
|
||||
const handleClick = () => {
|
||||
if (asSats === 'yep') {
|
||||
localStorage.setItem('asSats', '1btc')
|
||||
|
@ -66,7 +66,7 @@ export default function Price () {
|
|||
if (asSats === 'yep') {
|
||||
return (
|
||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||
{fixed(100000000 / price, 0) + ' sats/$'}
|
||||
{fixedDecimal(100000000 / price, 0) + ' sats/$'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ export default function Price () {
|
|||
|
||||
return (
|
||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||
{'$' + fixed(price, 0)}
|
||||
{'$' + fixedDecimal(price, 0)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ export const ITEM_FIELDS = gql`
|
|||
name
|
||||
baseCost
|
||||
}
|
||||
pollCost
|
||||
status
|
||||
uploadId
|
||||
mine
|
||||
|
@ -93,6 +94,16 @@ export const ITEM_FULL = gql`
|
|||
meComments
|
||||
position
|
||||
text
|
||||
poll {
|
||||
meVoted
|
||||
count
|
||||
options {
|
||||
id
|
||||
option
|
||||
count
|
||||
meVoted
|
||||
}
|
||||
}
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
}
|
||||
|
|
|
@ -11,3 +11,4 @@ export const UPLOAD_TYPES_ALLOW = [
|
|||
]
|
||||
export const COMMENT_DEPTH_LIMIT = 10
|
||||
export const MAX_TITLE_LENGTH = 80
|
||||
export const MAX_POLL_CHOICE_LENGTH = 30
|
||||
|
|
|
@ -5,3 +5,7 @@ export const formatSats = n => {
|
|||
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b'
|
||||
if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't'
|
||||
}
|
||||
|
||||
export const fixedDecimal = (n, f) => {
|
||||
return Number.parseFloat(n).toFixed(f)
|
||||
}
|
||||
|
|
22
lib/time.js
22
lib/time.js
|
@ -19,3 +19,25 @@ export function timeSince (timeStamp) {
|
|||
|
||||
return 'now'
|
||||
}
|
||||
|
||||
export function timeLeft (timeStamp) {
|
||||
const now = new Date()
|
||||
const secondsPast = (timeStamp - now.getTime()) / 1000
|
||||
|
||||
if (secondsPast < 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (secondsPast < 60) {
|
||||
return parseInt(secondsPast) + 's'
|
||||
}
|
||||
if (secondsPast < 3600) {
|
||||
return parseInt(secondsPast / 60) + 'm'
|
||||
}
|
||||
if (secondsPast <= 86400) {
|
||||
return parseInt(secondsPast / 3600) + 'h'
|
||||
}
|
||||
if (secondsPast > 86400) {
|
||||
return parseInt(secondsPast / (3600 * 24)) + ' days'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import { useMe } from '../components/me'
|
|||
import { DiscussionForm } from '../components/discussion-form'
|
||||
import { LinkForm } from '../components/link-form'
|
||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||
import AccordianItem from '../components/accordian-item'
|
||||
import { PollForm } from '../components/poll-form'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps()
|
||||
|
||||
|
@ -16,6 +18,9 @@ export function PostForm () {
|
|||
if (!router.query.type) {
|
||||
return (
|
||||
<div className='align-items-center'>
|
||||
{me?.freePosts
|
||||
? <div className='text-center font-weight-bold mb-3 text-success'>{me.freePosts} free posts left</div>
|
||||
: null}
|
||||
<Link href='/post?type=link'>
|
||||
<Button variant='secondary'>link</Button>
|
||||
</Link>
|
||||
|
@ -23,17 +28,27 @@ export function PostForm () {
|
|||
<Link href='/post?type=discussion'>
|
||||
<Button variant='secondary'>discussion</Button>
|
||||
</Link>
|
||||
{me?.freePosts
|
||||
? <div className='text-center font-weight-bold mt-3 text-success'>{me.freePosts} free posts left</div>
|
||||
: null}
|
||||
<div className='d-flex justify-content-center mt-3'>
|
||||
<AccordianItem
|
||||
headerColor='#6c757d'
|
||||
header={<div className='font-weight-bold text-muted'>more</div>}
|
||||
body={
|
||||
<Link href='/post?type=poll'>
|
||||
<Button variant='info'>poll</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (router.query.type === 'discussion') {
|
||||
return <DiscussionForm adv />
|
||||
} else {
|
||||
} else if (router.query.type === 'link') {
|
||||
return <LinkForm />
|
||||
} else {
|
||||
return <PollForm />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -97,6 +97,9 @@ const COLORS = [
|
|||
]
|
||||
|
||||
function GrowthAreaChart ({ data, xName, title }) {
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||
<AreaChart
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "ItemActType" ADD VALUE 'POLL';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PostType" ADD VALUE 'POLL';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "pollCost" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PollOption" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"option" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PollVote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"pollOptionId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PollOption.itemId_index" ON "PollOption"("itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PollVote.itemId_userId_unique" ON "PollVote"("itemId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PollVote.userId_index" ON "PollVote"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PollVote.pollOptionId_index" ON "PollVote"("pollOptionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PollOption" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PollVote" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PollVote" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PollVote" ADD FOREIGN KEY ("pollOptionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,23 @@
|
|||
-- create poll
|
||||
-- charges us to create poll
|
||||
-- adds poll options to poll
|
||||
CREATE OR REPLACE FUNCTION create_poll(title TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
option TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
item := create_item(title, null, null, boost, null, user_id);
|
||||
|
||||
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;
|
||||
$$;
|
|
@ -0,0 +1,112 @@
|
|||
CREATE OR REPLACE FUNCTION create_poll(title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
option TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
item := create_item(title, null, text, boost, null, user_id);
|
||||
|
||||
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;
|
||||
$$;
|
||||
|
||||
-- create poll vote
|
||||
-- if user hasn't already voted
|
||||
-- charges user item.pollCost
|
||||
-- adds POLL to ItemAct
|
||||
-- adds PollVote
|
||||
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
option "PollOption";
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT * INTO option FROM "PollOption" where id = option_id;
|
||||
IF option IS NULL THEN
|
||||
RAISE EXCEPTION 'INVALID_POLL_OPTION';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO item FROM "Item" where id = option."itemId";
|
||||
IF item IS NULL THEN
|
||||
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
|
||||
END IF;
|
||||
|
||||
IF item."userId" = user_id THEN
|
||||
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM "PollVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
|
||||
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
|
||||
END IF;
|
||||
|
||||
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
|
||||
|
||||
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId", "userId")
|
||||
VALUES (now_utc(), now_utc(), item.id, option_id, user_id);
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_sats INTEGER;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id;
|
||||
IF act_sats > user_sats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
END IF;
|
||||
|
||||
-- deduct sats from actor
|
||||
UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id;
|
||||
|
||||
IF act = 'BOOST' OR act = 'POLL' THEN
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc());
|
||||
ELSE
|
||||
-- add sats to actee's balance and stacked count
|
||||
UPDATE users
|
||||
SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000)
|
||||
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id);
|
||||
|
||||
-- if they have already voted, this is a tip
|
||||
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||
ELSE
|
||||
-- else this is a vote with a possible extra tip
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc());
|
||||
act_sats := act_sats - 1;
|
||||
|
||||
-- if we have sats left after vote, leave them as a tip
|
||||
IF act_sats > 0 THEN
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||
END IF;
|
||||
|
||||
RETURN 1;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;
|
|
@ -56,8 +56,9 @@ model User {
|
|||
noteInvites Boolean @default(true)
|
||||
noteJobIndicator Boolean @default(true)
|
||||
|
||||
Earn Earn[]
|
||||
Upload Upload[] @relation(name: "Uploads")
|
||||
Earn Earn[]
|
||||
Upload Upload[] @relation(name: "Uploads")
|
||||
PollVote PollVote[]
|
||||
@@index([createdAt])
|
||||
@@index([inviteId])
|
||||
@@map(name: "users")
|
||||
|
@ -180,7 +181,12 @@ model Item {
|
|||
longitude Float?
|
||||
remote Boolean?
|
||||
|
||||
User User[]
|
||||
// fields for polls
|
||||
pollCost Int?
|
||||
|
||||
User User[]
|
||||
PollOption PollOption[]
|
||||
PollVote PollVote[]
|
||||
@@index([createdAt])
|
||||
@@index([userId])
|
||||
@@index([parentId])
|
||||
|
@ -192,10 +198,39 @@ model Item {
|
|||
@@index([path])
|
||||
}
|
||||
|
||||
model PollOption {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
|
||||
itemId Int
|
||||
item Item @relation(fields: [itemId], references: [id])
|
||||
option String
|
||||
|
||||
PollVote PollVote[]
|
||||
@@index([itemId])
|
||||
}
|
||||
|
||||
model PollVote {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
itemId Int
|
||||
item Item @relation(fields: [itemId], references: [id])
|
||||
pollOptionId Int
|
||||
pollOption PollOption @relation(fields: [pollOptionId], references: [id])
|
||||
|
||||
@@unique([itemId, userId])
|
||||
@@index([userId])
|
||||
@@index([pollOptionId])
|
||||
}
|
||||
|
||||
enum PostType {
|
||||
LINK
|
||||
DISCUSSION
|
||||
JOB
|
||||
POLL
|
||||
}
|
||||
|
||||
enum RankingType {
|
||||
|
@ -232,6 +267,7 @@ enum ItemActType {
|
|||
BOOST
|
||||
TIP
|
||||
STREAM
|
||||
POLL
|
||||
}
|
||||
|
||||
model ItemAct {
|
||||
|
|
|
@ -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="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/></svg>
|
After Width: | Height: | Size: 171 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 3v4H3V3h9zm4 14v4H3v-4h13zm6-7v4H3v-4h19z"/></svg>
|
After Width: | Height: | Size: 183 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.997-6l7.07-7.071-1.414-1.414-5.656 5.657-2.829-2.829-1.414 1.414L11.003 16z"/></svg>
|
After Width: | Height: | Size: 289 B |
|
@ -16,7 +16,7 @@ function earn ({ models }) {
|
|||
FROM "ItemAct"
|
||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||
WHERE ("ItemAct".act in ('BOOST', 'STREAM')
|
||||
OR ("ItemAct".act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId"))
|
||||
OR ("ItemAct".act IN ('VOTE','POLL') AND "Item"."userId" = "ItemAct"."userId"))
|
||||
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
|
||||
|
||||
/*
|
||||
|
|
Loading…
Reference in New Issue