Fetch unsubmitted images from database
This commit is contained in:
parent
9ac774c0e7
commit
a61d00ba8b
@ -803,6 +803,21 @@ export default {
|
||||
)
|
||||
|
||||
return true
|
||||
},
|
||||
deleteImage: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
id = Number(id)
|
||||
|
||||
const img = await models.upload.findUnique({ where: { id } })
|
||||
if (img.userId !== me.id) {
|
||||
throw new GraphQLError('not your image', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
await models.upload.delete({ where: { id } })
|
||||
|
||||
return id
|
||||
}
|
||||
},
|
||||
Item: {
|
||||
|
@ -896,6 +896,16 @@ export default {
|
||||
return contributors.has(user.name)
|
||||
}
|
||||
return !user.hideIsContributor && contributors.has(user.name)
|
||||
},
|
||||
images: async (user, { submitted }, { me, models }) => {
|
||||
if (!me) return null
|
||||
const uploads = await models.upload.findMany({
|
||||
where: {
|
||||
userId: me.id,
|
||||
itemId: submitted !== undefined ? submitted ? { not: null } : null : undefined
|
||||
}
|
||||
})
|
||||
return uploads
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ export default gql`
|
||||
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Boolean!
|
||||
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
|
||||
pollVote(id: ID!, hash: String, hmac: String): ID!
|
||||
deleteImage(id: ID!): ID!
|
||||
}
|
||||
|
||||
type PollOption {
|
||||
|
@ -45,6 +45,18 @@ export default gql`
|
||||
email: String
|
||||
}
|
||||
|
||||
type Image {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
type: String!
|
||||
size: Int!
|
||||
width: Int
|
||||
height: Int
|
||||
itemId: Int
|
||||
userId: Int!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
@ -101,5 +113,6 @@ export default gql`
|
||||
meSubscriptionPosts: Boolean!
|
||||
meSubscriptionComments: Boolean!
|
||||
meMute: Boolean
|
||||
images(submitted: Boolean): [Image]
|
||||
}
|
||||
`
|
||||
|
@ -25,7 +25,7 @@ import textAreaCaret from 'textarea-caret'
|
||||
import ReactDatePicker from 'react-datepicker'
|
||||
import 'react-datepicker/dist/react-datepicker.css'
|
||||
import { debounce } from './use-debounce-callback'
|
||||
import { ImageUpload } from './image'
|
||||
import { ImageUpload, useImages } from './image'
|
||||
import LinkIcon from '../svgs/link.svg'
|
||||
|
||||
export function SubmitButton ({
|
||||
@ -95,7 +95,7 @@ export function InputSkeleton ({ label, hint }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ImageSelector ({ file, url, text, setText, onRemove }) {
|
||||
function ImageSelector ({ file, name, url, text, setText, onDelete }) {
|
||||
const onLink = () => {
|
||||
let newText = text ? text + '\n\n' : ''
|
||||
newText += url
|
||||
@ -104,10 +104,10 @@ function ImageSelector ({ file, url, text, setText, onRemove }) {
|
||||
|
||||
return (
|
||||
<span className='d-flex align-items-center text-muted'>
|
||||
<img className='me-1' src={window.URL.createObjectURL(file)} width={32} height={32} style={{ objectFit: 'contain' }} />
|
||||
<a href={url} target='_blank' rel='noreferrer'>{file.name}</a>
|
||||
<img className='me-1' src={file ? window.URL.createObjectURL(file) : url} width={32} height={32} style={{ objectFit: 'contain' }} />
|
||||
<a href={url} target='_blank' rel='noreferrer'>{name || url}</a>
|
||||
<LinkIcon className='ms-auto me-1' width={18} height={18} onClick={onLink} style={{ cursor: 'pointer' }} />
|
||||
<CloseIcon width={18} height={18} onClick={onRemove} style={{ cursor: 'pointer' }} />
|
||||
<CloseIcon width={18} height={18} onClick={onDelete} style={{ cursor: 'pointer' }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -119,6 +119,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
||||
innerRef = innerRef || useRef(null)
|
||||
const previousTab = useRef(tab)
|
||||
const toaster = useToast()
|
||||
const { unsubmittedImages, setUnsubmittedImages, deleteImage } = useImages()
|
||||
|
||||
props.as ||= TextareaAutosize
|
||||
props.rows ||= props.minRows || 6
|
||||
@ -229,11 +231,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
}
|
||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
||||
|
||||
const [uploadedImages, setUploadedImages] = useState([])
|
||||
const removeUploadedImage = useCallback((i) => {
|
||||
setUploadedImages(prev => prev.slice(0, i).concat(prev.slice(i + 1)))
|
||||
}, [setUploadedImages])
|
||||
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||
@ -246,9 +243,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
</Nav.Item>
|
||||
<span className='ms-auto text-muted d-flex align-items-center'>
|
||||
<ImageUpload
|
||||
className='d-flex align-items-center me-1' onSuccess={(file, url) => {
|
||||
setUploadedImages(prev => [...prev, { file, url }])
|
||||
}}
|
||||
className='d-flex align-items-center me-1'
|
||||
onSuccess={img => setUnsubmittedImages(prev => [...prev, img])}
|
||||
>
|
||||
<AddImageIcon width={18} height={18} />
|
||||
</ImageUpload>
|
||||
@ -274,8 +270,26 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
onBlur={() => setTimeout(resetSuggestions, 100)}
|
||||
/>)}
|
||||
</UserSuggest>
|
||||
{uploadedImages.map((props, i) => {
|
||||
return <ImageSelector key={i} {...props} text={meta.value} setText={helpers.setValue} onRemove={() => removeUploadedImage(i)} />
|
||||
{unsubmittedImages.map((img, i) => {
|
||||
return (
|
||||
<ImageSelector
|
||||
name={img.name}
|
||||
file={img.file}
|
||||
url={img.url}
|
||||
key={i}
|
||||
text={meta.value}
|
||||
setText={helpers.setValue}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await deleteImage({ variables: { id: img.id } })
|
||||
toaster.success('image deleted')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to delete image')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{tab !== 'write' &&
|
||||
|
@ -1,5 +1,5 @@
|
||||
import styles from './text.module.css'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, useRef, createContext, useContext } from 'react'
|
||||
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||
import { useShowModal } from './modal'
|
||||
import { useMe } from './me'
|
||||
@ -7,7 +7,67 @@ import { Dropdown } from 'react-bootstrap'
|
||||
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
|
||||
import { useToast } from './toast'
|
||||
import gql from 'graphql-tag'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
|
||||
const ImageContext = createContext({ unsubmitted: [] })
|
||||
|
||||
export function ImageProvider ({ me, children }) {
|
||||
const { data, loading } = useQuery(
|
||||
gql`
|
||||
query images($submitted: Boolean) {
|
||||
me {
|
||||
images(submitted: $submitted) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
type
|
||||
size
|
||||
width
|
||||
height
|
||||
itemId
|
||||
}
|
||||
}
|
||||
}`, {
|
||||
variables: { submitted: false }
|
||||
}
|
||||
)
|
||||
const [deleteImage] = useMutation(gql`
|
||||
mutation deleteImage($id: ID!) {
|
||||
deleteImage(id: $id)
|
||||
}`, {
|
||||
onCompleted: (_, options) => {
|
||||
const id = options.variables.id
|
||||
setUnsubmittedImages(prev => prev.filter(img => img.id !== id))
|
||||
}
|
||||
})
|
||||
const [unsubmittedImages, setUnsubmittedImages] = useState([])
|
||||
|
||||
const imageIdToUrl = id => `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${id}`
|
||||
|
||||
useEffect(() => {
|
||||
const images = data?.me?.images
|
||||
if (images) {
|
||||
setUnsubmittedImages(images.map(img => ({ ...img, url: imageIdToUrl(img.id) })))
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const contextValue = {
|
||||
unsubmittedImages,
|
||||
setUnsubmittedImages,
|
||||
deleteImage
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ImageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useImages () {
|
||||
const images = useContext(ImageContext)
|
||||
return images
|
||||
}
|
||||
|
||||
export function decodeOriginalUrl (imgproxyUrl) {
|
||||
const parts = imgproxyUrl.split('/')
|
||||
@ -155,15 +215,14 @@ export function ImageUpload ({ children, className, onSelect, onSuccess }) {
|
||||
img.src = window.URL.createObjectURL(file)
|
||||
img.onload = async () => {
|
||||
let data
|
||||
const variables = {
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width: img.width,
|
||||
height: img.height
|
||||
}
|
||||
try {
|
||||
({ data } = await getSignedPOST({
|
||||
variables: {
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width: img.width,
|
||||
height: img.height
|
||||
}
|
||||
}))
|
||||
({ data } = await getSignedPOST({ variables }))
|
||||
} catch (e) {
|
||||
toaster.danger(e.message || e.toString?.())
|
||||
return
|
||||
@ -188,7 +247,9 @@ export function ImageUpload ({ children, className, onSelect, onSuccess }) {
|
||||
}
|
||||
|
||||
const url = `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${data.getSignedPOST.fields.key}`
|
||||
onSuccess?.(file, url)
|
||||
// key is upload id in database
|
||||
const id = data.getSignedPOST.fields.key
|
||||
onSuccess?.({ ...variables, id, name: file.name, url, file })
|
||||
}
|
||||
}, [toaster, getSignedPOST])
|
||||
|
||||
|
@ -17,6 +17,7 @@ import { SSR } from '../lib/constants'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { LoggerProvider } from '../components/logger'
|
||||
import { ImageProvider } from '../components/image'
|
||||
|
||||
NProgress.configure({
|
||||
showSpinner: false
|
||||
@ -89,21 +90,23 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
|
||||
<ApolloProvider client={client}>
|
||||
<MeProvider me={me}>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</LoggerProvider>
|
||||
<ImageProvider>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</LoggerProvider>
|
||||
</ImageProvider>
|
||||
</MeProvider>
|
||||
</ApolloProvider>
|
||||
</PlausibleProvider>
|
||||
|
Loading…
x
Reference in New Issue
Block a user