diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 596dd790..a07d2a05 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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: { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 282678b3..06ce834c 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 } } } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 1b10eaf0..77d22735 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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 { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index d9184d0e..1808bb75 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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] } ` diff --git a/components/form.js b/components/form.js index 35dc277c..2cc58f39 100644 --- a/components/form.js +++ b/components/form.js @@ -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 ( - - {file.name} + + {name || url} - + ) } @@ -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 (
@@ -246,9 +243,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH { - setUploadedImages(prev => [...prev, { file, url }]) - }} + className='d-flex align-items-center me-1' + onSuccess={img => setUnsubmittedImages(prev => [...prev, img])} > @@ -274,8 +270,26 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH onBlur={() => setTimeout(resetSuggestions, 100)} />)} - {uploadedImages.map((props, i) => { - return removeUploadedImage(i)} /> + {unsubmittedImages.map((img, i) => { + return ( + { + try { + await deleteImage({ variables: { id: img.id } }) + toaster.success('image deleted') + } catch (err) { + console.error(err) + toaster.danger('failed to delete image') + } + }} + /> + ) })}
{tab !== 'write' && diff --git a/components/image.js b/components/image.js index 89bd63b6..22796ef5 100644 --- a/components/image.js +++ b/components/image.js @@ -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 ( + + {children} + + ) +} + +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]) diff --git a/pages/_app.js b/pages/_app.js index e41b9c18..a29ef1f3 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -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 } }) { - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + +