diff --git a/public/locales/bg/common.json b/public/locales/bg/common.json index ae80523fc..17451819b 100644 --- a/public/locales/bg/common.json +++ b/public/locales/bg/common.json @@ -130,6 +130,12 @@ "agree-with-newsletter": "Съгласявам се да получавам известия.", "agree-with-newsletter-campaign": "Съгласявам се да получавам новини за тази кампания и известия от Подкрепи.бг." }, + "files": { + "attached-files": "Прикачени файлове", + "download": "Изтегляне", + "errorDeletingFile": "Грешка при изтриване на файл", + "deletedFile": "Успешно изтрит файл" + }, "cookieConsent": "Подкрепи.бг не използва бисквитки, освен тези от трети страни, нужни за аналитичните компоненти Google Analytics и HotJar. Приемането на бисквитките ще ни помогне да подобрим вашето потребителско преживяване.", "cookieConsentButton": "Приемам", "cookieRejectButton": "Отхвърлям", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d0af58071..39dbaa6db 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -130,6 +130,12 @@ "agree-with-newsletter": "I agree to receive news.", "agree-with-newsletter-campaign": "I agree to receive news about this campaign and news by Podkrepi.bg." }, + "files": { + "attached-files": "Attached files", + "download": "Download", + "errorDeletingFile": "Failure deleting file", + "deletedFile": "Successfully deleted file" + }, "cookieConsent": "Podkrepi.bg doesn't use cookies, except the third-party cookies required for the analytics components Google Analytics and HotJar. Accepting the cookies will help us improve your user experience.", "cookieConsentButton": "Accept", diff --git a/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx b/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx index 52171a6c7..0bfaeec3a 100644 --- a/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx +++ b/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx @@ -9,8 +9,16 @@ import { import { CamAppDetail } from 'components/client/campaign-application/steps/CampaignApplicationSummary' import CheckboxField from 'components/common/form/CheckboxField' import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt' +import { UploadedFile } from 'components/common/file-upload/UploadedFilesList' +import UploadedCampaignApplicationFiles from './UploadedCampaignApplicationFiles' -export default function CampaignApplicationAdminPropsEdit({ id }: { id: string }) { +export default function CampaignApplicationAdminPropsEdit({ + id, + files, +}: { + id: string + files: UploadedFile[] +}) { const { t } = useTranslation('campaign-application') return ( <Grid container spacing={6} justifyContent="center" direction="column" alignContent="center"> @@ -40,6 +48,9 @@ export default function CampaignApplicationAdminPropsEdit({ id }: { id: string } value={<OrganizerCanEditAt id={id} />} /> </Grid> + <Grid item> + <UploadedCampaignApplicationFiles files={files} campaignApplicationId={id} /> + </Grid> </Grid> ) } diff --git a/src/components/admin/campaign-applications/EditPage.tsx b/src/components/admin/campaign-applications/EditPage.tsx index 7fdc80e11..76aa23936 100644 --- a/src/components/admin/campaign-applications/EditPage.tsx +++ b/src/components/admin/campaign-applications/EditPage.tsx @@ -2,6 +2,7 @@ import { Box, CircularProgress, Grid, Typography } from '@mui/material' import { red } from '@mui/material/colors' import { CampaignApplicationFormData } from 'components/client/campaign-application/helpers/campaignApplication.types' import { ActionSubmitButton } from 'components/client/campaign-application/helpers/campaignApplicationFormActions.styled' +import { campaignApplicationAdminValidationSchema } from 'components/client/campaign-application/helpers/validation-schema' import CampaignApplicationBasic from 'components/client/campaign-application/steps/CampaignApplicationBasic' import CampaignApplicationDetails from 'components/client/campaign-application/steps/CampaignApplicationDetails' import CampaignApplicationOrganizer from 'components/client/campaign-application/steps/CampaignApplicationOrganizer' @@ -21,7 +22,6 @@ import { } from 'service/campaign-application' import CampaignApplicationAdminPropsEdit from './CampaignApplicationAdminPropsEdit' import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt' -import { campaignApplicationAdminValidationSchema } from 'components/client/campaign-application/helpers/validation-schema' export type Props = { id: string @@ -83,7 +83,7 @@ export function EditLoadedCampaign({ campaign }: { campaign: CampaignApplication }} initialValues={c.initialValues} validationSchema={campaignApplicationAdminValidationSchema.defined()}> - <CampaignApplicationAdminPropsEdit id={campaign.id} /> + <CampaignApplicationAdminPropsEdit id={campaign.id} files={campaign.documents} /> <CampaignApplicationOrganizer isAdmin={true} /> <CampaignApplicationBasic /> <CampaignApplicationDetails files={c.files} setFiles={c.setFiles} /> diff --git a/src/components/admin/campaign-applications/UploadedCampaignApplicationFiles.tsx b/src/components/admin/campaign-applications/UploadedCampaignApplicationFiles.tsx new file mode 100644 index 000000000..457dcbf7b --- /dev/null +++ b/src/components/admin/campaign-applications/UploadedCampaignApplicationFiles.tsx @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { useTranslation } from 'next-i18next' + +import { endpoints } from 'service/apiEndpoints' +import { ApiErrors } from 'service/apiErrors' + +import { UploadedFile, UploadedFilesList } from 'components/common/file-upload/UploadedFilesList' + +import { useSession } from 'next-auth/react' +import { + fetchCampaignApplicationFile, + useDeleteCampaignApplicationFile, +} from 'service/campaign-application' +import { AlertStore } from 'stores/AlertStore' + +type Props = { + campaignApplicationId: string + files: UploadedFile[] +} + +export default function UploadedCampaignApplicationFiles({ files, campaignApplicationId }: Props) { + const { t } = useTranslation(['common', 'campaign-applications']) + const queryClient = useQueryClient() + const { data: session } = useSession() + + const del = useMutation<unknown, AxiosError<ApiErrors>, string>({ + mutationFn: (fileId) => useDeleteCampaignApplicationFile(session)(fileId), + onError: () => AlertStore.show(t('common:alerts.errorDeletingFile'), 'error'), + onSuccess: () => { + AlertStore.show(t('common:files.deletedFile'), 'success') + queryClient.invalidateQueries([endpoints.campaignApplication.view(campaignApplicationId).url]) + }, + }) + + return ( + <UploadedFilesList + files={files} + downloadQuery={(f) => fetchCampaignApplicationFile(f.id, session).then((r) => r.data)} + deleteMutation={(f) => del.mutateAsync(f.id)} + /> + ) +} diff --git a/src/components/common/file-upload/UploadedFilesList.tsx b/src/components/common/file-upload/UploadedFilesList.tsx new file mode 100644 index 000000000..4c3e15a25 --- /dev/null +++ b/src/components/common/file-upload/UploadedFilesList.tsx @@ -0,0 +1,218 @@ +import { Close, Delete, FilePresent, Preview } from '@mui/icons-material' +import { + Avatar, + Button, + IconButton, + List, + ListItem, + ListItemAvatar, + ListItemText, + Tooltip, +} from '@mui/material' +import { useTranslation } from 'next-i18next' +import { createContext, useContext, useEffect, useState } from 'react' +import CenteredSpinner from '../CenteredSpinner' + +export interface FilesListContext { + deleteMutation: (file: UploadedFile) => Promise<unknown> + files: UploadedFile[] + getBlobUrl: (file: UploadedFile) => Promise<string> + download: (file: UploadedFile) => void +} + +const UploadedFilesContext = createContext<FilesListContext>({ + files: [], + download: () => Promise.reject('download query missing'), + deleteMutation: () => Promise.reject('delete mutation missing'), + getBlobUrl: () => Promise.reject('Function not implemented.'), +}) + +export interface UploadedFilesListProps { + title?: string | JSX.Element | undefined + files: UploadedFile[] + downloadQuery: (f: UploadedFile) => Promise<Blob> + deleteMutation: (f: UploadedFile) => Promise<unknown> + downloadText?: string +} + +export function UploadedFilesList({ + title: maybeTitle, + files, + downloadQuery, + deleteMutation, + downloadText: maybeDownloadText, +}: UploadedFilesListProps) { + const { t } = useTranslation('common') + const filesCtx = filesListContext({ + files, + downloadQuery, + deleteMutation, + }) + + const title = maybeTitle != null ?? t('files.attached-files') + const downloadText = maybeDownloadText ?? t('files.download') + + return ( + <UploadedFilesContext.Provider value={filesCtx}> + <List dense> + {typeof title === 'string' ? <ListItemText primary={title} /> : title} + {(files ?? []).map((file) => ( + <UploadedFileView key={file.id} file={file} downloadText={downloadText} /> + ))} + </List> + </UploadedFilesContext.Provider> + ) +} + +export type UploadedFileViewProps = { + file: UploadedFile + role?: string + downloadText: string +} + +export function UploadedFileView({ file, role, downloadText }: UploadedFileViewProps) { + const { deleteMutation, download } = useContext(UploadedFilesContext) + + return ( + <ListItem key={file.id}> + <ListItemAvatar> + <Avatar> + <FilePresent /> + </Avatar> + </ListItemAvatar> + <ListItemText primary={file.filename} /> + {role && <ListItemText primary={role} sx={{ textAlign: 'right', pr: 'inherit' }} />} + <></> + <Tooltip title={'download'}> + <Button onClick={() => download(file)}>{downloadText}</Button> + </Tooltip> + <FilePreview {...file} /> + <IconButton edge="end" aria-label="delete" onClick={() => deleteMutation(file)}> + <Delete /> + </IconButton> + </ListItem> + ) +} + +export function FilePreview(f: UploadedFile) { + const { getBlobUrl } = useContext(UploadedFilesContext) + const [fetch, setFetch] = useState(false) + const [display, setDisplay] = useState<'block' | 'none'>('none') + const [blobUrl, setBlobUrl] = useState<string | undefined>() + + useEffect(() => { + if (fetch) { + setDisplay('block') + getBlobUrl(f) + .then(setBlobUrl) + .then(() => setFetch(false)) + .catch((e) => { + console.log(e) + }) + } + }, [fetch]) + return ( + <> + <Tooltip title={'preview'}> + <IconButton edge="end" aria-label="preview" onClick={() => setFetch(true)}> + <Preview /> + </IconButton> + </Tooltip> + <div + style={{ + position: 'fixed', + top: '5vw', + left: '5vh', + boxShadow: '2px 4px 5px', + width: '90vw', + height: '90vh', + zIndex: 99999, + display, + backgroundColor: 'white', + }}> + {display === 'block' ? ( + <iframe + id={f.id} + src={blobUrl} + allowFullScreen={true} + style={{ width: '100%', height: '100%' }} + /> + ) : ( + <div style={{ width: '100%', height: '100%' }}> + <CenteredSpinner /> + </div> + )} + <Button + color="secondary" + variant="outlined" + sx={{ position: 'absolute', right: '-2rem', top: '-2.5rem' }} + onClick={() => setDisplay('none')}> + <Close /> + </Button> + </div> + </> + ) +} + +export interface FilesListContextInput { + files: UploadedFile[] + downloadQuery: (file: UploadedFile) => Promise<Blob> + deleteMutation: (file: UploadedFile) => Promise<unknown> +} +export function filesListContext({ files, downloadQuery, deleteMutation }: FilesListContextInput) { + const blobUrls: Record<string, string> = {} + async function downloadAndGetBlobUrl(file: UploadedFile) { + if (blobUrls[file.id]) { + return blobUrls[file.id] + } else { + return downloadQuery(file) + .then((blob) => { + const b = window.URL.createObjectURL(new Blob([blob], { type: blob.type })) + blobUrls[file.id] = b + return b + }) + .catch((error) => { + console.error(error) + // don't store it so next time we'll retry fetching + return window.URL.createObjectURL( + new Blob( + [ + `<html> + <body> + <h3 style="text-align:center; margin-top: 10%"> + Could not fetch the file. Please retry + </h3> + </body> + </html>`, + ], + { + type: 'text/html', + }, + ), + ) + }) + } + } + + async function download(f: UploadedFile) { + const blobUrlForDownload = await downloadAndGetBlobUrl(f) + const link = document.createElement('a') + link.href = blobUrlForDownload + link.setAttribute('download', `${f.filename}`) + link.click() + } + + const context: FilesListContext = { + deleteMutation, + files, + download, + getBlobUrl: downloadAndGetBlobUrl, + } + + return context +} + +export interface UploadedFile { + id: string + filename: string +} diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index 39a455d0c..ec3e81262 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -433,5 +433,7 @@ export const endpoints = { <Endpoint>{ url: `/campaign-application/fileById/${fileId}`, method: 'DELETE' }, view: (id: string) => <Endpoint>{ url: `/campaign-application/byId/${id}`, method: 'GET' }, listAllForAdmin: <Endpoint>{ url: `/campaign-application/list`, method: 'GET' }, + getFile: (fileId: string) => + <Endpoint>{ url: `/campaign-application/fileById/${fileId}`, method: 'GET' }, }, } diff --git a/src/service/campaign-application.ts b/src/service/campaign-application.ts index 58874e3ae..c909b5536 100644 --- a/src/service/campaign-application.ts +++ b/src/service/campaign-application.ts @@ -20,6 +20,7 @@ import { apiClient } from 'service/apiClient' import { endpoints } from 'service/apiEndpoints' import { authConfig, authQueryFnFactory } from 'service/restRequests' import { ApiErrors } from './apiErrors' +import { Session } from 'next-auth' export const useCreateCampaignApplication = () => { const { data: session } = useSession() @@ -51,8 +52,8 @@ export const useUploadCampaignApplicationFiles = () => { } } -export const useDeleteCampaignApplicationFile = () => { - const { data: session } = useSession() +export const useDeleteCampaignApplicationFile = (s?: Session | null) => { + const { data: session } = s != null ? { data: s } : useSession() return async (id: string) => await apiClient.delete<UploadCampaignApplicationFilesRequest>( endpoints.campaignApplication.deleteFile(id).url, @@ -300,3 +301,13 @@ export function mapCreateOrEditInput(i: CampaignApplicationFormData): CampaignAp ...(i.admin ?? {}), // server disregards admin-only props if the user is not admin } } + +export function fetchCampaignApplicationFile(fileId: string, session: Session | null) { + return apiClient.get<string, AxiosResponse<Blob>>( + endpoints.campaignApplication.getFile(fileId).url, + { + ...authConfig(session?.accessToken), + responseType: 'blob', + }, + ) +}