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 ( @@ -40,6 +48,9 @@ export default function CampaignApplicationAdminPropsEdit({ id }: { id: string } value={} /> + + + ) } 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()}> - + 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, 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 ( + 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 + files: UploadedFile[] + getBlobUrl: (file: UploadedFile) => Promise + download: (file: UploadedFile) => void +} + +const UploadedFilesContext = createContext({ + 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 + deleteMutation: (f: UploadedFile) => Promise + 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 ( + + + {typeof title === 'string' ? : title} + {(files ?? []).map((file) => ( + + ))} + + + ) +} + +export type UploadedFileViewProps = { + file: UploadedFile + role?: string + downloadText: string +} + +export function UploadedFileView({ file, role, downloadText }: UploadedFileViewProps) { + const { deleteMutation, download } = useContext(UploadedFilesContext) + + return ( + + + + + + + + {role && } + <>> + + download(file)}>{downloadText} + + + deleteMutation(file)}> + + + + ) +} + +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() + + useEffect(() => { + if (fetch) { + setDisplay('block') + getBlobUrl(f) + .then(setBlobUrl) + .then(() => setFetch(false)) + .catch((e) => { + console.log(e) + }) + } + }, [fetch]) + return ( + <> + + setFetch(true)}> + + + + + {display === 'block' ? ( + + ) : ( + + + + )} + setDisplay('none')}> + + + + > + ) +} + +export interface FilesListContextInput { + files: UploadedFile[] + downloadQuery: (file: UploadedFile) => Promise + deleteMutation: (file: UploadedFile) => Promise +} +export function filesListContext({ files, downloadQuery, deleteMutation }: FilesListContextInput) { + const blobUrls: Record = {} + 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( + [ + ` + + + Could not fetch the file. Please retry + + + `, + ], + { + 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 = { { url: `/campaign-application/fileById/${fileId}`, method: 'DELETE' }, view: (id: string) => { url: `/campaign-application/byId/${id}`, method: 'GET' }, listAllForAdmin: { url: `/campaign-application/list`, method: 'GET' }, + getFile: (fileId: string) => + { 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( 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>( + endpoints.campaignApplication.getFile(fileId).url, + { + ...authConfig(session?.accessToken), + responseType: 'blob', + }, + ) +}