Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add image uploader for campaign editor #1449

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export default function RenderBankDonationStatusCell({ params }: RenderCellProps

const handleError = (e: AxiosError<ApiError>) => {
const error = e.response?.data?.message
console.log(e.response)
AlertStore.show(error ? error : t('common:alerts.error'), 'error')
}

Expand Down
18 changes: 16 additions & 2 deletions src/components/admin/campaigns/grid/EditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { fromMoney, toMoney } from 'common/util/money'
import CurrencySelect from 'components/common/currency/CurrencySelect'
import OrganizerSelect from './OrganizerSelect'
import AllowDonationOnComplete from '../../../common/form/AllowDonationOnComplete'
import { base64ImageUploader } from './helpers/base64ImageUploader'

const formatString = 'yyyy-MM-dd'

Expand All @@ -67,7 +68,7 @@ const validationSchema: yup.SchemaOf<Omit<CampaignEditFormData, 'campaignFiles'>
.shape({
title: yup.string().trim().min(10).max(200).required(),
slug: yup.string().trim().min(10).max(200).required(),
description: yup.string().trim().min(50).max(60000).required(),
description: yup.string().trim().min(50).required(),
targetAmount: yup.number().integer().positive().required(),
allowDonationOnComplete: yup.bool().optional(),
campaignTypeId: yup.string().uuid().required(),
Expand Down Expand Up @@ -181,10 +182,23 @@ export default function EditForm({ campaign }: { campaign: AdminSingleCampaignRe
{ setFieldError }: FormikHelpers<CampaignEditFormData>,
) => {
try {
//replace base64 images with uploaded images
const descriptionWithServerImages = await base64ImageUploader(
values.description,
campaign.id,
fileUploadMutation,
)

//this manual validation is needed because yup validations happen before the base64ImageUploader
if (descriptionWithServerImages.length > 60000) {
return AlertStore.show(t('Description field should be less than 60,000 symbols'), 'error')
}

//save campaign
await mutation.mutateAsync({
title: values.title,
slug: createSlug(values.slug),
description: values.description,
description: descriptionWithServerImages,
targetAmount: toMoney(values.targetAmount),
allowDonationOnComplete: values.allowDonationOnComplete,
startDate: values.startDate,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { UseMutationResult } from '@tanstack/react-query'
import { AxiosError, AxiosResponse } from 'axios'
import { CampaignFileRole, UploadCampaignFiles } from 'components/common/campaign-file/roles'
import { CampaignUploadImage } from 'gql/campaigns'
import { ApiErrors } from 'service/apiErrors'
import crypto from 'crypto'
import getConfig from 'next/config'

const { publicRuntimeConfig } = getConfig()

/**
* This function finds all base64 image links in the given string, uploads them to the server
* and replaces them with the podkrepi.bg server links.
*/
export const base64ImageUploader = async function (
textWithLinks: string,
campaignId: string,
fileUploadMutation: UseMutationResult<
AxiosResponse<CampaignUploadImage[]>,
AxiosError<ApiErrors>,
UploadCampaignFiles,
unknown
>,
): Promise<string> {
const urlPromises: Promise<string>[] = []

//this is a replacer function that will be called for each base64 image
const getReplacementUrls = async (
_matchedBase64: string,
imageType: string,
base64Data: string,
) => {
//upload the imageData to the server and get the url
const fileUrl = await uploadImage(campaignId, base64Data, imageType, fileUploadMutation)
return fileUrl
}

//now call the replacer to upload all images and return the urls
const base64regex = /data:image\/(png|jpg|jpeg|gif);base64,([^"]+)/g
textWithLinks.replaceAll(base64regex, (m, p1, p2) => {
urlPromises.push(getReplacementUrls(m, p1, p2))
return ''
})

//and wait on all replacement promisses in the array and run the replacer again with the returned urls
return Promise.all(urlPromises).then((urls) => {
textWithLinks = textWithLinks.replaceAll(base64regex, () => urls.shift() as string)
return textWithLinks
})
}

//upload image to server and return the url
async function uploadImage(
campaignId: string,
base64Data: string,
imageType: string,
fileUploadMutation: UseMutationResult<
AxiosResponse<CampaignUploadImage[]>,
AxiosError<ApiErrors>,
UploadCampaignFiles,
unknown
>,
): Promise<string> {
const imageBuffer = Buffer.from(base64Data, 'base64')

//we don't know the name so we hash the image for unique name to avoid uploading the same image twice
const fileName = crypto.createHash('sha256').update(imageBuffer).digest('hex') + '.' + imageType
const imageFile = new File([imageBuffer], fileName, { type: 'image/' + imageType })

return await fileUploadMutation
.mutateAsync({
campaignId: campaignId,
files: [imageFile],
roles: [{ file: imageFile.name, role: CampaignFileRole.campaignPhoto }],
})
.then((response) => {
return `${publicRuntimeConfig.APP_URL}/api/v1/campaign-file/` + response.data[0]
})
.catch(() => {
return ''
})
}
1 change: 0 additions & 1 deletion src/components/admin/donations/grid/GridFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export default observer(function GridFilters() {
filterName: string,
filterValue: string | number | null | { from: Date; to: Date },
) => {
console.log('Setting filter:', filterName, filterValue)
donationStore.setDonationFilters(filterName, filterValue)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ function RadioCardGroup({ options, defaultValue }: RadioCardGroupProps) {
setValue(event.target.value)
}

console.log(theme.typography.h1)
return (
<FormControl>
<RadioGroup
Expand Down
Original file line number Diff line number Diff line change
@@ -1,78 +1,78 @@
import Link from 'next/link'
import { Grid, Typography } from '@mui/material'
import theme from 'common/theme'
import { routes } from 'common/routes'
import { beneficiaryCampaignPictureUrl } from 'common/util/campaignImageUrls'
import Layout from 'components/client/layout/Layout'
import { useViewCampaign } from 'common/hooks/campaigns'
import CenteredSpinner from 'components/common/CenteredSpinner'
import DonationStepper from '../Steps'
import {
BeneficiaryAvatar,
BeneficiaryAvatarWrapper,
StepperWrapper,
} from './OneTimeDonation.styles'
// import RadioAccordionGroup, { testRadioOptions } from 'components/donation-flow/common/RadioAccordionGroup'
// import RadioCardGroup, { testRadioOptions } from 'components/donation-flow/common/RadioCardGroup'
// import PaymentDetailsStripeForm from 'components/admin/donations/stripe/PaymentDetailsStripeForm'
const scrollWindow = () => {
window.scrollTo({ top: 200, behavior: 'smooth' })
}
export default function OneTimeDonation({ slug }: { slug: string }) {
const { data, isLoading } = useViewCampaign(slug)
// const paymentIntentMutation = useCreatePaymentIntent({
// amount: 100,
// currency: 'BGN',
// })
// useEffect(() => {
// paymentIntentMutation.mutate()
// }, [])
if (isLoading || !data) return <CenteredSpinner size="2rem" />
const { campaign } = data
const beneficiaryAvatarSource = beneficiaryCampaignPictureUrl(campaign)
return (
<Layout maxWidth={false}>
<Grid container component="section" maxWidth="lg" justifyContent="center" m="0 auto">
<BeneficiaryAvatarWrapper item xs={12} p={4}>
<BeneficiaryAvatar
src={beneficiaryAvatarSource}
// A11Y TODO: Translate alt text
alt={`Image of ${campaign.beneficiary.person?.firstName} ${campaign.beneficiary.person?.lastName}`}
width={250}
height={250}
/>
</BeneficiaryAvatarWrapper>
<StepperWrapper>
<Link href={routes.campaigns.viewCampaignBySlug(campaign.slug)} passHref>
<Typography
variant="h4"
color="info.dark"
sx={{ textAlign: 'center', marginBottom: theme.spacing(4) }}>
{campaign.title}
</Typography>
</Link>
{/* {paymentIntentMutation.isLoading ? (
<CenteredSpinner size="2rem" />
) : (
<PaymentDetailsStripeForm
clientSecret={paymentIntentMutation.data?.data.client_secret as string}
containerProps={{ maxWidth: 400 }}
/>
)} */}
<DonationStepper onStepChange={scrollWindow} />
{/* <RadioCardGroup options={testRadioOptions} /> */}
{/* <RadioAccordionGroup options={testRadioOptions} /> */}
</StepperWrapper>
</Grid>
</Layout>
)
}
import Link from 'next/link'

import { Grid, Typography } from '@mui/material'

import theme from 'common/theme'
import { routes } from 'common/routes'
import { beneficiaryCampaignPictureUrl } from 'common/util/campaignImageUrls'
import Layout from 'components/client/layout/Layout'
import { useViewCampaign } from 'common/hooks/campaigns'
import CenteredSpinner from 'components/common/CenteredSpinner'
import DonationStepper from '../Steps'

import {
BeneficiaryAvatar,
BeneficiaryAvatarWrapper,
StepperWrapper,
} from './OneTimeDonation.styles'

// import RadioAccordionGroup, { testRadioOptions } from 'components/donation-flow/common/RadioAccordionGroup'
// import RadioCardGroup, { testRadioOptions } from 'components/donation-flow/common/RadioCardGroup'
// import PaymentDetailsStripeForm from 'components/admin/donations/stripe/PaymentDetailsStripeForm'

const scrollWindow = () => {
window.scrollTo({ top: 200, behavior: 'smooth' })
}

export default function OneTimeDonation({ slug }: { slug: string }) {
const { data, isLoading } = useViewCampaign(slug)
// const paymentIntentMutation = useCreatePaymentIntent({
// amount: 100,
// currency: 'BGN',
// })
// useEffect(() => {
// paymentIntentMutation.mutate()
// }, [])
if (isLoading || !data) return <CenteredSpinner size="2rem" />

const { campaign } = data

const beneficiaryAvatarSource = beneficiaryCampaignPictureUrl(campaign)

return (
<Layout maxWidth={false}>
<Grid container component="section" maxWidth="lg" justifyContent="center" m="0 auto">
<BeneficiaryAvatarWrapper item xs={12} p={4}>
<BeneficiaryAvatar
src={beneficiaryAvatarSource}
// A11Y TODO: Translate alt text
alt={`Image of ${campaign.beneficiary.person?.firstName} ${campaign.beneficiary.person?.lastName}`}
width={250}
height={250}
/>
</BeneficiaryAvatarWrapper>
<StepperWrapper>
<Link href={routes.campaigns.viewCampaignBySlug(campaign.slug)} passHref>
<Typography
variant="h4"
color="info.dark"
sx={{ textAlign: 'center', marginBottom: theme.spacing(4) }}>
{campaign.title}
</Typography>
</Link>
{/* {paymentIntentMutation.isLoading ? (
<CenteredSpinner size="2rem" />
) : (
<PaymentDetailsStripeForm
clientSecret={paymentIntentMutation.data?.data.client_secret as string}
containerProps={{ maxWidth: 400 }}
/>
)} */}
<DonationStepper onStepChange={scrollWindow} />
{/* <RadioCardGroup options={testRadioOptions} /> */}
{/* <RadioAccordionGroup options={testRadioOptions} /> */}
</StepperWrapper>
</Grid>
</Layout>
)
}
16 changes: 10 additions & 6 deletions src/components/common/form/FormRichTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,28 @@ import BlotFormatter from 'quill-blot-formatter/'
Quill.register('modules/blotFormatter', BlotFormatter)

import htmlEditButton from 'quill-html-edit-button'

Quill.register({
'modules/htmlEditButton': htmlEditButton,
})

Quill.register({
'modules/htmlEditButton': htmlEditButton,
})

export type RegisterFormProps = {
export type FormRichTextFieldProps = {
name: string
}

export default function FormRichTextField({ name }: RegisterFormProps) {
export default function FormRichTextField({ name }: FormRichTextFieldProps) {
const { t } = useTranslation()
const [, meta] = useField(name)
const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : ''

const reactQuillRef = useRef<ReactQuill>(null)

//this image handler inserts the image into the editor as URL to eternally hosted image
//TODO: find a way to upload the image to our backend
function handleImageInsert() {
//this image handler inserts the image into the editor as URL to externally hosted image
function handleImageUrlInsert() {
let imageUrl = prompt('Enter the URL of the image:') // for a better UX find a way to use the Quill Tooltip or a Modal box
if (!imageUrl) return
const editor = reactQuillRef.current?.getEditor()
Expand Down Expand Up @@ -65,7 +69,7 @@ export default function FormRichTextField({ name }: RegisterFormProps) {
['link', 'video', 'image'],
['clean'],
],
handlers: { image: handleImageInsert },
handlers: { image: handleImageUrlInsert },
},
clipboard: {
// toggle to add extra line breaks when pasting HTML:
Expand Down
2 changes: 0 additions & 2 deletions src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,6 @@ export const authOptions: NextAuthOptions = {
session.accessToken = token.accessToken
session.refreshToken = token.refreshToken

console.log('Returning session from api/auth')

return session
},
async jwt({ token, user, account }) {
Expand Down
3 changes: 0 additions & 3 deletions src/service/restRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ const {
export async function fetchSession(): Promise<Session | null> {
const res = await apiClient.get('/api/auth/session', { baseURL: APP_URL })
const session = res.data
console.log('Fetching session from /api/auth/session')

console.log(session)
if (Object.keys(session).length) {
console.log('Fetching session successful.')
return session
}
console.warn('Fetching session returned null.')
Expand Down