Fetch unsubmitted images from database

This commit is contained in:
ekzyis 2023-10-19 16:34:20 +02:00 committed by ekzyis
parent 9ac774c0e7
commit a61d00ba8b
7 changed files with 158 additions and 41 deletions

View File

@ -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: {

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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]
}
`

View File

@ -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' &&

View File

@ -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])

View File

@ -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>