job board enhancements

This commit is contained in:
keyan 2022-07-21 17:55:05 -05:00
parent 70cbdd057a
commit cb313429d5
19 changed files with 289 additions and 206 deletions

View File

@ -135,13 +135,24 @@ export default {
// we pull from their wallet
// TODO: need to filter out by payment status
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status <> 'STOPPED'
ORDER BY (CASE WHEN status = 'ACTIVE' THEN "maxBid" ELSE 0 END) DESC, created_at ASC
SELECT *
FROM (
(${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'ACTIVE'
ORDER BY "maxBid" DESC, created_at ASC)
UNION ALL
(${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'NOSATS'
ORDER BY created_at DESC)
) a
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
break
@ -447,7 +458,10 @@ export default {
return await createItem(parent, data, { me, models })
}
},
upsertJob: async (parent, { id, sub, title, company, location, remote, text, url, maxBid, status }, { me, models }) => {
upsertJob: async (parent, {
id, sub, title, company, location, remote,
text, url, maxBid, status, logo
}, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in to create job')
}
@ -483,7 +497,8 @@ export default {
url,
maxBid,
subName: sub,
userId: me.id
userId: me.id,
uploadId: logo
}
if (id) {
@ -837,7 +852,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, ltree2text("Item"."path") AS "path"`
"Item"."subName", "Item".status, "Item"."uploadId", ltree2text("Item"."path") AS "path"`
function newTimedOrderByWeightedSats (num) {
return `

View File

@ -21,7 +21,8 @@ export default gql`
extend type Mutation {
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item!
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): 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!
createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): Item!
act(id: ID!, sats: Int): ItemActResult!
@ -71,5 +72,6 @@ export default gql`
remote: Boolean
sub: Sub
status: String
uploadId: Int
}
`

74
components/avatar.js Normal file
View File

@ -0,0 +1,74 @@
import { useRef, useState } from 'react'
import AvatarEditor from 'react-avatar-editor'
import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap'
import Upload from './upload'
import EditImage from '../svgs/image-edit-fill.svg'
import Moon from '../svgs/moon-fill.svg'
export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState()
const [editProps, setEditProps] = useState()
const ref = useRef()
const [scale, setScale] = useState(1)
return (
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
}}
onSuccess={async key => {
onSuccess && onSuccess(key)
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
</>
)
}

View File

@ -1,4 +1,5 @@
import Item, { ItemJob } from './item'
import Item from './item'
import ItemJob from './item-job'
import Reply from './reply'
import Comment from './comment'
import Text from './text'

96
components/item-job.js Normal file
View File

@ -0,0 +1,96 @@
import * as Yup from 'yup'
import Toc from './table-of-contents'
import { Button, Image } from 'react-bootstrap'
import { SearchTitle } from './item'
import styles from './item.module.css'
import Link from 'next/link'
import { timeSince } from '../lib/time'
import EmailIcon from '../svgs/mail-open-line.svg'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
return (
<>
{rank
? (
<div className={`${styles.rank} align-self-center`}>
{rank}
</div>)
: <div />}
<div className={`${styles.item} ${item.status === 'NOSATS' && !item.mine ? styles.itemDead : ''}`}>
<Link href={`/items/${item.id}`} passHref>
<a>
<Image
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
/>
</a>
</Link>
<div className={`${styles.hunk} align-self-center mb-0`}>
<div className={`${styles.main} flex-wrap d-inline`}>
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}</>)}
</a>
</Link>
</div>
<div className={`${styles.other}`}>
{item.status === 'NOSATS' &&
<>
<span>expired</span>
{item.company && <span> \ </span>}
</>}
{item.company &&
<>
{item.company}
</>}
{(item.location || item.remote) &&
<>
<span> \ </span>
{`${item.location || ''}${item.location && item.remote ? ' or ' : ''}${item.remote ? 'Remote' : ''}`}
</>}
<wbr />
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
</span>
{item.mine &&
<>
<wbr />
<span> \ </span>
<Link href={`/items/${item.id}/edit`} passHref>
<a className='text-reset'>
edit
</a>
</Link>
{item.status !== 'ACTIVE' && <span className='font-weight-bold text-danger'> {item.status}</span>}
</>}
</div>
</div>
{toc && <Toc text={item.text} />}
</div>
{children && (
<div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}>
<div className='mb-3 d-flex'>
<Button
target='_blank' href={isEmail ? `mailto:${item.url}?subject=${encodeURIComponent(item.title)} via Stacker News` : item.url}
>
apply {isEmail && <EmailIcon className='ml-1' />}
</Button>
{isEmail && <div className='ml-3 align-self-center text-muted font-weight-bold'>{item.url}</div>}
</div>
{children}
</div>
)}
</>
)
}

View File

@ -7,100 +7,14 @@ import Countdown from './countdown'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
import { formatSats } from '../lib/format'
import * as Yup from 'yup'
import Briefcase from '../svgs/briefcase-4-fill.svg'
import Toc from './table-of-contents'
function SearchTitle ({ title }) {
export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
return <mark key={`mark-${match}`}>{match}</mark>
})
}
export function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
return (
<>
{rank
? (
<div className={styles.rank}>
{rank}
</div>)
: <div />}
<div className={`${styles.item} ${item.status === 'NOSATS' && !item.mine ? styles.itemDead : ''}`}>
<Briefcase width={24} height={24} className={styles.case} />
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap d-inline`}>
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}
{item.company &&
<>
<span> \ </span>
{item.company}
</>}
{(item.location || item.remote) &&
<>
<span> \ </span>
{`${item.location || ''}${item.location && item.remote ? ' or ' : ''}${item.remote ? 'Remote' : ''}`}
</>}
</>)}
</a>
</Link>
{/* eslint-disable-next-line */}
<a
className={`${styles.link}`}
target='_blank' href={(isEmail ? 'mailto:' : '') + item.url}
>
apply
</a>
</div>
<div className={`${styles.other}`}>
{item.status !== 'NOSATS'
? <span>{formatSats(item.maxBid)} sats per min</span>
: <span>expired</span>}
<span> \ </span>
<Link href={`/items/${item.id}`} passHref>
<a className='text-reset'>{item.ncomments} comments</a>
</Link>
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
</span>
{item.mine &&
<>
<span> \ </span>
<Link href={`/items/${item.id}/edit`} passHref>
<a className='text-reset'>
edit
</a>
</Link>
{item.status !== 'ACTIVE' && <span className='font-weight-bold text-danger'> {item.status}</span>}
</>}
</div>
</div>
{toc && <Toc text={item.text} />}
</div>
{children && (
<div className={`${styles.children}`}>
{children}
</div>
)}
</>
)
}
function FwdUser ({ user }) {
return (
<div className={styles.other}>

View File

@ -41,13 +41,20 @@ a.link:visited {
.other {
font-size: 80%;
color: var(--theme-grey);
margin-bottom: .15rem;
}
.item {
display: flex;
justify-content: flex-start;
min-width: 0;
margin-bottom: .45rem;
}
.item .companyImage {
border-radius: 100%;
align-self: center;
margin-right: 0.5rem;
margin-left: 0.3rem;
}
.itemDead {
@ -62,10 +69,17 @@ a.link:visited {
.hunk {
overflow: hidden;
width: 100%;
margin-bottom: .3rem;
line-height: 1.06rem;
}
/* .itemJob .hunk {
align-self: center;
}
.itemJob .rank {
align-self: center;
} */
.main {
display: flex;
align-items: baseline;

View File

@ -1,5 +1,6 @@
import { useQuery } from '@apollo/client'
import Item, { ItemJob, ItemSkeleton } from './item'
import Item, { ItemSkeleton } from './item'
import ItemJob from './item-job'
import styles from './items.module.css'
import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer'

View File

@ -1,6 +1,6 @@
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import TextareaAutosize from 'react-textarea-autosize'
import { InputGroup, Form as BForm, Col } from 'react-bootstrap'
import { InputGroup, Form as BForm, Col, Image } from 'react-bootstrap'
import * as Yup from 'yup'
import { useEffect, useState } from 'react'
import Info from './info'
@ -10,6 +10,7 @@ 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'
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
return this.test({
@ -47,6 +48,7 @@ export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter()
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost))
const [logoId, setLogoId] = useState(item?.uploadId)
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
@ -54,10 +56,10 @@ export default function JobForm ({ item, sub }) {
{ fetchPolicy: 'network-only' })
const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String) {
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
upsertJob(sub: "${sub.name}", id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status) {
url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
id
}
}`
@ -122,6 +124,7 @@ export default function JobForm ({ item, sub }) {
sub: sub.name,
maxBid: Number(maxBid),
status,
logo: Number(logoId),
...values
}
})
@ -136,6 +139,15 @@ export default function JobForm ({ item, sub }) {
}
})}
>
<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_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
/>
<Avatar onSuccess={setLogoId} />
</div>
</div>
<Input
label='job title'
name='title'

View File

@ -1,6 +1,7 @@
import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item, { ItemJob } from './item'
import Item from './item'
import ItemJob from './item-job'
import { NOTIFICATIONS } from '../fragments/notifications'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'

View File

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

View File

@ -1,8 +1,8 @@
import { Button, InputGroup, Image, Modal, Form as BootstrapForm } from 'react-bootstrap'
import { Button, InputGroup, Image } from 'react-bootstrap'
import Link from 'next/link'
import { useRouter } from 'next/router'
import Nav from 'react-bootstrap/Nav'
import { useRef, useState } from 'react'
import { useState } from 'react'
import { Form, Input, SubmitButton } from './form'
import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client'
@ -13,10 +13,7 @@ import QRCode from 'qrcode.react'
import LightningIcon from '../svgs/bolt.svg'
import ModalButton from './modal-button'
import { encodeLNUrl } from '../lib/lnurl'
import Upload from './upload'
import EditImage from '../svgs/image-edit-fill.svg'
import Moon from '../svgs/moon-fill.svg'
import AvatarEditor from 'react-avatar-editor'
import Avatar from './avatar'
export default function UserHeader ({ user }) {
const [editting, setEditting] = useState(false)
@ -25,6 +22,24 @@ export default function UserHeader ({ user }) {
const client = useApolloClient()
const [setName] = useMutation(NAME_MUTATION)
const [setPhoto] = useMutation(
gql`
mutation setPhoto($photoId: ID!) {
setPhoto(photoId: $photoId)
}`, {
update (cache, { data: { setPhoto } }) {
cache.modify({
id: `User:${user.id}`,
fields: {
photoId () {
return setPhoto
}
}
})
}
}
)
const isMe = me?.name === user.name
const Satistics = () => <div className={`mb-2 ml-0 ml-sm-1 ${styles.username} text-success`}>{isMe ? `${user.sats} sats \\ ` : ''}{user.stacked} stacked</div>
@ -54,7 +69,14 @@ export default function UserHeader ({ user }) {
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
className={styles.userimg}
/>
{isMe && <PhotoEditor userId={me.id} />}
{isMe &&
<Avatar onSuccess={async photoId => {
const { error } = await setPhoto({ variables: { photoId } })
if (error) {
console.log(error)
}
}}
/>}
</div>
<div className='ml-0 ml-sm-1 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
{editting
@ -161,92 +183,3 @@ export default function UserHeader ({ user }) {
</>
)
}
function PhotoEditor ({ userId }) {
const [uploading, setUploading] = useState()
const [editProps, setEditProps] = useState()
const ref = useRef()
const [scale, setScale] = useState(1)
const [setPhoto] = useMutation(
gql`
mutation setPhoto($photoId: ID!) {
setPhoto(photoId: $photoId)
}`, {
update (cache, { data: { setPhoto } }) {
cache.modify({
id: `User:${userId}`,
fields: {
photoId () {
return setPhoto
}
}
})
}
}
)
return (
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
}}
onSuccess={async key => {
const { error } = await setPhoto({ variables: { photoId: key } })
if (error) {
console.log(error)
}
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
</>
)
}

View File

@ -31,6 +31,7 @@ export const ITEM_FIELDS = gql`
baseCost
}
status
uploadId
mine
root {
id

View File

@ -29,6 +29,11 @@ export const SUB_ITEMS = gql`
cursor
items {
...ItemFields
position
},
pins {
...ItemFields
position
}
}
}

View File

@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[itemId]` on the table `Upload` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "uploadId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Upload.itemId_unique" ON "Upload"("itemId");

View File

@ -72,7 +72,7 @@ model Upload {
width Int?
height Int?
item Item? @relation(fields: [itemId], references: [id])
itemId Int?
itemId Int? @unique
user User @relation(name: "Uploads", fields: [userId], references: [id])
userId Int
@ -161,6 +161,8 @@ model Item {
pinId Int?
weightedVotes Float @default(0)
boost Int @default(0)
uploadId Int?
upload Upload?
// if sub is null, this is the main sub
sub Sub? @relation(fields: [subName], references: [name])
@ -178,8 +180,7 @@ model Item {
longitude Float?
remote Boolean?
User User[]
Upload Upload[]
User User[]
@@index([createdAt])
@@index([userId])
@@index([parentId])

BIN
public/jobs-default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

1
svgs/mail-open-fill.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2.243 6.854L11.49 1.31a1 1 0 0 1 1.029 0l9.238 5.545a.5.5 0 0 1 .243.429V20a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.283a.5.5 0 0 1 .243-.429zm16.103 1.39l-6.285 5.439-6.414-5.445-1.294 1.524 7.72 6.555 7.581-6.56-1.308-1.513z"/></svg>

After

Width:  |  Height:  |  Size: 356 B

1
svgs/mail-open-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2.243 6.854L11.49 1.31a1 1 0 0 1 1.029 0l9.238 5.545a.5.5 0 0 1 .243.429V20a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.283a.5.5 0 0 1 .243-.429zM4 8.133V19h16V8.132l-7.996-4.8L4 8.132zm8.06 5.565l5.296-4.463 1.288 1.53-6.57 5.537-6.71-5.53 1.272-1.544 5.424 4.47z"/></svg>

After

Width:  |  Height:  |  Size: 391 B