job board enhancements
This commit is contained in:
		
							parent
							
								
									70cbdd057a
								
							
						
					
					
						commit
						cb313429d5
					
				| @ -135,13 +135,24 @@ export default { | |||||||
|               // we pull from their wallet
 |               // we pull from their wallet
 | ||||||
|               // TODO: need to filter out by payment status
 |               // TODO: need to filter out by payment status
 | ||||||
|               items = await models.$queryRaw(` |               items = await models.$queryRaw(` | ||||||
|                 ${SELECT} |                 SELECT * | ||||||
|                 FROM "Item" |                 FROM ( | ||||||
|                 WHERE "parentId" IS NULL AND created_at <= $1 |                   (${SELECT} | ||||||
|                 AND "pinId" IS NULL |                   FROM "Item" | ||||||
|                 ${subClause(3)} |                   WHERE "parentId" IS NULL AND created_at <= $1 | ||||||
|                 AND status <> 'STOPPED' |                   AND "pinId" IS NULL | ||||||
|                 ORDER BY (CASE WHEN status = 'ACTIVE' THEN "maxBid" ELSE 0 END) DESC, created_at ASC |                   ${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 |                 OFFSET $2 | ||||||
|                 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
 |                 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
 | ||||||
|               break |               break | ||||||
| @ -447,7 +458,10 @@ export default { | |||||||
|         return await createItem(parent, data, { me, models }) |         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) { |       if (!me) { | ||||||
|         throw new AuthenticationError('you must be logged in to create job') |         throw new AuthenticationError('you must be logged in to create job') | ||||||
|       } |       } | ||||||
| @ -483,7 +497,8 @@ export default { | |||||||
|         url, |         url, | ||||||
|         maxBid, |         maxBid, | ||||||
|         subName: sub, |         subName: sub, | ||||||
|         userId: me.id |         userId: me.id, | ||||||
|  |         uploadId: logo | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (id) { |       if (id) { | ||||||
| @ -837,7 +852,7 @@ export const SELECT = | |||||||
|   `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
 |   `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
 | ||||||
|   "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", |   "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", | ||||||
|   "Item".company, "Item".location, "Item".remote, |   "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) { | function newTimedOrderByWeightedSats (num) { | ||||||
|   return ` |   return ` | ||||||
|  | |||||||
| @ -21,7 +21,8 @@ export default gql` | |||||||
|   extend type Mutation { |   extend type Mutation { | ||||||
|     upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): 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! |     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! |     createComment(text: String!, parentId: ID!): Item! | ||||||
|     updateComment(id: ID!, text: String!): Item! |     updateComment(id: ID!, text: String!): Item! | ||||||
|     act(id: ID!, sats: Int): ItemActResult! |     act(id: ID!, sats: Int): ItemActResult! | ||||||
| @ -71,5 +72,6 @@ export default gql` | |||||||
|     remote: Boolean |     remote: Boolean | ||||||
|     sub: Sub |     sub: Sub | ||||||
|     status: String |     status: String | ||||||
|  |     uploadId: Int | ||||||
|   } |   } | ||||||
| ` | ` | ||||||
|  | |||||||
							
								
								
									
										74
									
								
								components/avatar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								components/avatar.js
									
									
									
									
									
										Normal 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) | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ) | ||||||
|  | } | ||||||
| @ -1,4 +1,5 @@ | |||||||
| import Item, { ItemJob } from './item' | import Item from './item' | ||||||
|  | import ItemJob from './item-job' | ||||||
| import Reply from './reply' | import Reply from './reply' | ||||||
| import Comment from './comment' | import Comment from './comment' | ||||||
| import Text from './text' | import Text from './text' | ||||||
|  | |||||||
							
								
								
									
										96
									
								
								components/item-job.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								components/item-job.js
									
									
									
									
									
										Normal 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> | ||||||
|  |       )} | ||||||
|  |     </> | ||||||
|  |   ) | ||||||
|  | } | ||||||
| @ -7,100 +7,14 @@ import Countdown from './countdown' | |||||||
| import { NOFOLLOW_LIMIT } from '../lib/constants' | import { NOFOLLOW_LIMIT } from '../lib/constants' | ||||||
| import Pin from '../svgs/pushpin-fill.svg' | import Pin from '../svgs/pushpin-fill.svg' | ||||||
| import reactStringReplace from 'react-string-replace' | import reactStringReplace from 'react-string-replace' | ||||||
| import { formatSats } from '../lib/format' |  | ||||||
| import * as Yup from 'yup' |  | ||||||
| import Briefcase from '../svgs/briefcase-4-fill.svg' |  | ||||||
| import Toc from './table-of-contents' | import Toc from './table-of-contents' | ||||||
| 
 | 
 | ||||||
| function SearchTitle ({ title }) { | export function SearchTitle ({ title }) { | ||||||
|   return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { |   return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { | ||||||
|     return <mark key={`mark-${match}`}>{match}</mark> |     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 }) { | function FwdUser ({ user }) { | ||||||
|   return ( |   return ( | ||||||
|     <div className={styles.other}> |     <div className={styles.other}> | ||||||
|  | |||||||
| @ -41,13 +41,20 @@ a.link:visited { | |||||||
| .other { | .other { | ||||||
|     font-size: 80%; |     font-size: 80%; | ||||||
|     color: var(--theme-grey); |     color: var(--theme-grey); | ||||||
|     margin-bottom: .15rem; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .item { | .item { | ||||||
|     display: flex; |     display: flex; | ||||||
|     justify-content: flex-start; |     justify-content: flex-start; | ||||||
|     min-width: 0; |     min-width: 0; | ||||||
|  |     margin-bottom: .45rem; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .item .companyImage { | ||||||
|  |     border-radius: 100%; | ||||||
|  |     align-self: center; | ||||||
|  |     margin-right: 0.5rem; | ||||||
|  |     margin-left: 0.3rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .itemDead { | .itemDead { | ||||||
| @ -62,10 +69,17 @@ a.link:visited { | |||||||
| .hunk { | .hunk { | ||||||
|     overflow: hidden; |     overflow: hidden; | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     margin-bottom: .3rem; |  | ||||||
|     line-height: 1.06rem; |     line-height: 1.06rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /* .itemJob .hunk { | ||||||
|  |     align-self: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .itemJob .rank { | ||||||
|  |     align-self: center; | ||||||
|  | } */ | ||||||
|  | 
 | ||||||
| .main { | .main { | ||||||
|     display: flex; |     display: flex; | ||||||
|     align-items: baseline; |     align-items: baseline; | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { useQuery } from '@apollo/client' | 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 styles from './items.module.css' | ||||||
| import { ITEMS } from '../fragments/items' | import { ITEMS } from '../fragments/items' | ||||||
| import MoreFooter from './more-footer' | import MoreFooter from './more-footer' | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form' | import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form' | ||||||
| import TextareaAutosize from 'react-textarea-autosize' | 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 * as Yup from 'yup' | ||||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||||
| import Info from './info' | import Info from './info' | ||||||
| @ -10,6 +10,7 @@ import { useLazyQuery, gql, useMutation } from '@apollo/client' | |||||||
| import { useRouter } from 'next/router' | import { useRouter } from 'next/router' | ||||||
| import Link from 'next/link' | import Link from 'next/link' | ||||||
| import { usePrice } from './price' | import { usePrice } from './price' | ||||||
|  | import Avatar from './avatar' | ||||||
| 
 | 
 | ||||||
| Yup.addMethod(Yup.string, 'or', function (schemas, msg) { | Yup.addMethod(Yup.string, 'or', function (schemas, msg) { | ||||||
|   return this.test({ |   return this.test({ | ||||||
| @ -47,6 +48,7 @@ export default function JobForm ({ item, sub }) { | |||||||
|   const storageKeyPrefix = item ? undefined : `${sub.name}-job` |   const storageKeyPrefix = item ? undefined : `${sub.name}-job` | ||||||
|   const router = useRouter() |   const router = useRouter() | ||||||
|   const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost)) |   const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost)) | ||||||
|  |   const [logoId, setLogoId] = useState(item?.uploadId) | ||||||
|   const [getAuctionPosition, { data }] = useLazyQuery(gql` |   const [getAuctionPosition, { data }] = useLazyQuery(gql` | ||||||
|     query AuctionPosition($id: ID, $bid: Int!) { |     query AuctionPosition($id: ID, $bid: Int!) { | ||||||
|       auctionPosition(sub: "${sub.name}", id: $id, bid: $bid) |       auctionPosition(sub: "${sub.name}", id: $id, bid: $bid) | ||||||
| @ -54,10 +56,10 @@ export default function JobForm ({ item, sub }) { | |||||||
|   { fetchPolicy: 'network-only' }) |   { fetchPolicy: 'network-only' }) | ||||||
|   const [upsertJob] = useMutation(gql` |   const [upsertJob] = useMutation(gql` | ||||||
|     mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String, |     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, |       upsertJob(sub: "${sub.name}", id: $id, title: $title, company: $company, | ||||||
|         location: $location, remote: $remote, text: $text, |         location: $location, remote: $remote, text: $text, | ||||||
|         url: $url, maxBid: $maxBid, status: $status) { |         url: $url, maxBid: $maxBid, status: $status, logo: $logo) { | ||||||
|         id |         id | ||||||
|       } |       } | ||||||
|     }` |     }` | ||||||
| @ -122,6 +124,7 @@ export default function JobForm ({ item, sub }) { | |||||||
|               sub: sub.name, |               sub: sub.name, | ||||||
|               maxBid: Number(maxBid), |               maxBid: Number(maxBid), | ||||||
|               status, |               status, | ||||||
|  |               logo: Number(logoId), | ||||||
|               ...values |               ...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 |         <Input | ||||||
|           label='job title' |           label='job title' | ||||||
|           name='title' |           name='title' | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| import { useQuery } from '@apollo/client' | import { useQuery } from '@apollo/client' | ||||||
| import Comment, { CommentSkeleton } from './comment' | 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 { NOTIFICATIONS } from '../fragments/notifications' | ||||||
| import { useRouter } from 'next/router' | import { useRouter } from 'next/router' | ||||||
| import MoreFooter from './more-footer' | import MoreFooter from './more-footer' | ||||||
|  | |||||||
| @ -24,7 +24,7 @@ export default function Toc ({ text }) { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Dropdown alignRight> |     <Dropdown alignRight className='d-flex align-items-center'> | ||||||
|       <Dropdown.Toggle as={CustomToggle} id='dropdown-custom-components'> |       <Dropdown.Toggle as={CustomToggle} id='dropdown-custom-components'> | ||||||
|         <TocIcon className='mx-2 fill-grey theme' /> |         <TocIcon className='mx-2 fill-grey theme' /> | ||||||
|       </Dropdown.Toggle> |       </Dropdown.Toggle> | ||||||
|  | |||||||
| @ -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 Link from 'next/link' | ||||||
| import { useRouter } from 'next/router' | import { useRouter } from 'next/router' | ||||||
| import Nav from 'react-bootstrap/Nav' | import Nav from 'react-bootstrap/Nav' | ||||||
| import { useRef, useState } from 'react' | import { useState } from 'react' | ||||||
| import { Form, Input, SubmitButton } from './form' | import { Form, Input, SubmitButton } from './form' | ||||||
| import * as Yup from 'yup' | import * as Yup from 'yup' | ||||||
| import { gql, useApolloClient, useMutation } from '@apollo/client' | import { gql, useApolloClient, useMutation } from '@apollo/client' | ||||||
| @ -13,10 +13,7 @@ import QRCode from 'qrcode.react' | |||||||
| import LightningIcon from '../svgs/bolt.svg' | import LightningIcon from '../svgs/bolt.svg' | ||||||
| import ModalButton from './modal-button' | import ModalButton from './modal-button' | ||||||
| import { encodeLNUrl } from '../lib/lnurl' | import { encodeLNUrl } from '../lib/lnurl' | ||||||
| import Upload from './upload' | import Avatar from './avatar' | ||||||
| import EditImage from '../svgs/image-edit-fill.svg' |  | ||||||
| import Moon from '../svgs/moon-fill.svg' |  | ||||||
| import AvatarEditor from 'react-avatar-editor' |  | ||||||
| 
 | 
 | ||||||
| export default function UserHeader ({ user }) { | export default function UserHeader ({ user }) { | ||||||
|   const [editting, setEditting] = useState(false) |   const [editting, setEditting] = useState(false) | ||||||
| @ -25,6 +22,24 @@ export default function UserHeader ({ user }) { | |||||||
|   const client = useApolloClient() |   const client = useApolloClient() | ||||||
|   const [setName] = useMutation(NAME_MUTATION) |   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 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> |   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' |             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} |             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> | ||||||
|         <div className='ml-0 ml-sm-1 mt-3 mt-sm-0 justify-content-center align-self-sm-center'> |         <div className='ml-0 ml-sm-1 mt-3 mt-sm-0 justify-content-center align-self-sm-center'> | ||||||
|           {editting |           {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) |  | ||||||
|         }} |  | ||||||
|       /> |  | ||||||
|     </> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ export const ITEM_FIELDS = gql` | |||||||
|       baseCost |       baseCost | ||||||
|     } |     } | ||||||
|     status |     status | ||||||
|  |     uploadId | ||||||
|     mine |     mine | ||||||
|     root { |     root { | ||||||
|       id |       id | ||||||
|  | |||||||
| @ -29,6 +29,11 @@ export const SUB_ITEMS = gql` | |||||||
|       cursor |       cursor | ||||||
|       items { |       items { | ||||||
|         ...ItemFields |         ...ItemFields | ||||||
|  |         position | ||||||
|  |       }, | ||||||
|  |       pins { | ||||||
|  |         ...ItemFields | ||||||
|  |         position | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								prisma/migrations/20220720211644_item_uploads/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								prisma/migrations/20220720211644_item_uploads/migration.sql
									
									
									
									
									
										Normal 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"); | ||||||
| @ -72,7 +72,7 @@ model Upload { | |||||||
|   width     Int? |   width     Int? | ||||||
|   height    Int? |   height    Int? | ||||||
|   item      Item?    @relation(fields: [itemId], references: [id]) |   item      Item?    @relation(fields: [itemId], references: [id]) | ||||||
|   itemId    Int? |   itemId    Int?     @unique | ||||||
|   user      User     @relation(name: "Uploads", fields: [userId], references: [id]) |   user      User     @relation(name: "Uploads", fields: [userId], references: [id]) | ||||||
|   userId    Int |   userId    Int | ||||||
| 
 | 
 | ||||||
| @ -161,6 +161,8 @@ model Item { | |||||||
|   pinId         Int? |   pinId         Int? | ||||||
|   weightedVotes Float                 @default(0) |   weightedVotes Float                 @default(0) | ||||||
|   boost         Int                   @default(0) |   boost         Int                   @default(0) | ||||||
|  |   uploadId      Int? | ||||||
|  |   upload        Upload? | ||||||
| 
 | 
 | ||||||
|   // if sub is null, this is the main sub |   // if sub is null, this is the main sub | ||||||
|   sub     Sub?    @relation(fields: [subName], references: [name]) |   sub     Sub?    @relation(fields: [subName], references: [name]) | ||||||
| @ -178,8 +180,7 @@ model Item { | |||||||
|   longitude       Float? |   longitude       Float? | ||||||
|   remote          Boolean? |   remote          Boolean? | ||||||
| 
 | 
 | ||||||
|   User   User[] |   User User[] | ||||||
|   Upload Upload[] |  | ||||||
|   @@index([createdAt]) |   @@index([createdAt]) | ||||||
|   @@index([userId]) |   @@index([userId]) | ||||||
|   @@index([parentId]) |   @@index([parentId]) | ||||||
|  | |||||||
							
								
								
									
										
											BIN
										
									
								
								public/jobs-default.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											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
									
								
							
							
						
						
									
										1
									
								
								svgs/mail-open-fill.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="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
									
								
							
							
						
						
									
										1
									
								
								svgs/mail-open-line.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="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 | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user