8f590425dc
* Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
278 lines
8.8 KiB
JavaScript
278 lines
8.8 KiB
JavaScript
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
|
|
import Row from 'react-bootstrap/Row'
|
|
import Col from 'react-bootstrap/Col'
|
|
import InputGroup from 'react-bootstrap/InputGroup'
|
|
import Image from 'react-bootstrap/Image'
|
|
import BootstrapForm from 'react-bootstrap/Form'
|
|
import Alert from 'react-bootstrap/Alert'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import Info from './info'
|
|
import AccordianItem from './accordian-item'
|
|
import styles from '../styles/post.module.css'
|
|
import { useLazyQuery, gql, useMutation } from '@apollo/client'
|
|
import { useRouter } from 'next/router'
|
|
import Link from 'next/link'
|
|
import { usePrice } from './price'
|
|
import Avatar from './avatar'
|
|
import ActionTooltip from './action-tooltip'
|
|
import { jobSchema } from '../lib/validate'
|
|
import CancelButton from './cancel-button'
|
|
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
|
|
|
function satsMin2Mo (minute) {
|
|
return minute * 30 * 24 * 60
|
|
}
|
|
|
|
function PriceHint ({ monthly }) {
|
|
const { price, fiatSymbol } = usePrice()
|
|
|
|
if (!price || !monthly) {
|
|
return null
|
|
}
|
|
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
|
|
const fiat = fixed((price / 100000000) * monthly, 0)
|
|
|
|
return <span className='text-muted'>{monthly} sats/mo which is {fiatSymbol}{fiat}/mo</span>
|
|
}
|
|
|
|
// need to recent list items
|
|
export default function JobForm ({ item, sub }) {
|
|
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
|
|
const router = useRouter()
|
|
const [logoId, setLogoId] = useState(item?.uploadId)
|
|
const [upsertJob] = useMutation(gql`
|
|
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
|
|
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int, $hash: String, $hmac: String) {
|
|
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
|
|
location: $location, remote: $remote, text: $text,
|
|
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
|
|
id
|
|
}
|
|
}`
|
|
)
|
|
|
|
const onSubmit = useCallback(
|
|
async ({ maxBid, start, stop, ...values }) => {
|
|
let status
|
|
if (start) {
|
|
status = 'ACTIVE'
|
|
} else if (stop) {
|
|
status = 'STOPPED'
|
|
}
|
|
|
|
const { error } = await upsertJob({
|
|
variables: {
|
|
id: item?.id,
|
|
sub: item?.subName || sub?.name,
|
|
maxBid: Number(maxBid),
|
|
status,
|
|
logo: Number(logoId),
|
|
...values
|
|
}
|
|
})
|
|
if (error) {
|
|
throw new Error({ message: error.toString() })
|
|
}
|
|
|
|
if (item) {
|
|
await router.push(`/items/${item.id}`)
|
|
} else {
|
|
await router.push(`/~${sub.name}/recent`)
|
|
}
|
|
}, [upsertJob, router, logoId]
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<Form
|
|
className='pb-5 pt-3'
|
|
initial={{
|
|
title: item?.title || '',
|
|
company: item?.company || '',
|
|
location: item?.location || '',
|
|
remote: item?.remote || false,
|
|
text: item?.text || '',
|
|
url: item?.url || '',
|
|
maxBid: item?.maxBid || 0,
|
|
stop: false,
|
|
start: false
|
|
}}
|
|
schema={jobSchema}
|
|
storageKeyPrefix={storageKeyPrefix}
|
|
invoiceable={{ requireSession: true }}
|
|
onSubmit={onSubmit}
|
|
>
|
|
<div className='form-group'>
|
|
<label className='form-label'>logo</label>
|
|
<div className='position-relative' style={{ width: 'fit-content' }}>
|
|
<Image
|
|
src={logoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
|
|
/>
|
|
<Avatar onSuccess={setLogoId} />
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label='job title'
|
|
name='title'
|
|
required
|
|
autoFocus
|
|
clear
|
|
maxLength={MAX_TITLE_LENGTH}
|
|
/>
|
|
<Input
|
|
label='company'
|
|
name='company'
|
|
required
|
|
clear
|
|
/>
|
|
<Row className='me-0'>
|
|
<Col>
|
|
<Input
|
|
label='location'
|
|
name='location'
|
|
clear
|
|
/>
|
|
</Col>
|
|
<Col className='d-flex ps-0' xs='auto'>
|
|
<Checkbox
|
|
label={<div className='fw-bold'>remote</div>} name='remote' hiddenLabel
|
|
groupClassName={styles.inlineCheckGroup}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
<MarkdownInput
|
|
topLevel
|
|
label='description'
|
|
name='text'
|
|
minRows={6}
|
|
required
|
|
/>
|
|
<Input
|
|
label={<>how to apply <small className='text-muted ms-2'>url or email address</small></>}
|
|
name='url'
|
|
required
|
|
clear
|
|
/>
|
|
<PromoteJob item={item} sub={sub} />
|
|
{item && <StatusControl item={item} />}
|
|
<div className='d-flex align-items-center justify-content-end mt-3'>
|
|
{item
|
|
? (
|
|
<div className='d-flex'>
|
|
<CancelButton />
|
|
<SubmitButton variant='secondary'>save</SubmitButton>
|
|
</div>
|
|
)
|
|
: (
|
|
<ActionTooltip overlayText='1000 sats'>
|
|
<SubmitButton cost={1000} variant='secondary'>post <small> 1000 sats</small></SubmitButton>
|
|
</ActionTooltip>
|
|
)}
|
|
</div>
|
|
</Form>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function PromoteJob ({ item, sub }) {
|
|
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
|
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
|
query AuctionPosition($id: ID, $bid: Int!) {
|
|
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
|
|
}`,
|
|
{ fetchPolicy: 'cache-and-network' })
|
|
const position = data?.auctionPosition
|
|
|
|
useEffect(() => {
|
|
const initialMaxBid = Number(item?.maxBid) || 0
|
|
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
|
|
setMonthly(satsMin2Mo(initialMaxBid))
|
|
}, [])
|
|
|
|
return (
|
|
<AccordianItem
|
|
show={item?.maxBid > 0}
|
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
|
body={
|
|
<>
|
|
<Input
|
|
label={
|
|
<div className='d-flex align-items-center'>bid
|
|
<Info>
|
|
<ol className='fw-bold'>
|
|
<li>The higher your bid the higher your job will rank</li>
|
|
<li>You can increase, decrease, or remove your bid at anytime</li>
|
|
<li>You can edit or stop your job at anytime</li>
|
|
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
|
|
</ol>
|
|
</Info>
|
|
<small className='text-muted ms-2'>optional</small>
|
|
</div>
|
|
}
|
|
name='maxBid'
|
|
onChange={async (formik, e) => {
|
|
if (e.target.value >= 0 && e.target.value <= 100000000) {
|
|
setMonthly(satsMin2Mo(e.target.value))
|
|
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
|
|
} else {
|
|
setMonthly(satsMin2Mo(0))
|
|
}
|
|
}}
|
|
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
|
|
hint={<PriceHint monthly={monthly} />}
|
|
/>
|
|
<><div className='fw-bold text-muted'>This bid puts your job in position: {position}</div></>
|
|
</>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function StatusControl ({ item }) {
|
|
let StatusComp
|
|
|
|
if (item.status !== 'STOPPED') {
|
|
StatusComp = () => {
|
|
return (
|
|
<>
|
|
|
|
<AccordianItem
|
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to stop my job</div>}
|
|
headerColor='var(--bs-danger)'
|
|
body={
|
|
<Checkbox
|
|
label={<div className='fw-bold text-danger'>stop my job</div>} name='stop' inline
|
|
/>
|
|
}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
} else if (item.status === 'STOPPED') {
|
|
StatusComp = () => {
|
|
return (
|
|
<AccordianItem
|
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to resume my job</div>}
|
|
headerColor='var(--bs-success)'
|
|
body={
|
|
<Checkbox
|
|
label={<div className='fw-bold text-success'>resume my job</div>} name='start' inline
|
|
/>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className='my-3 border border-3 rounded'>
|
|
<div className='p-3'>
|
|
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
|
{item.status === 'NOSATS' &&
|
|
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
|
|
<StatusComp />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|