diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts index 0150db8..acbca2f 100644 --- a/app/Root/config/routes.ts +++ b/app/Root/config/routes.ts @@ -210,6 +210,18 @@ const editLink: RouteConfig = { visibility: 'is-authenticated', }; +const createGalleryAlbum: RouteConfig = { + path: '/galleries/new', + load: () => import('#views/Galleries/GalleryForm'), + visibility: 'is-authenticated', +}; + +const editGalleryAlbum: RouteConfig = { + path: '/galleries/:id/edit', + load: () => import('#views/Galleries/GalleryForm'), + visibility: 'is-authenticated', +}; + const routes = { home, login, @@ -239,6 +251,8 @@ const routes = { documents, onlineInteractive, galleries, + createGalleryAlbum, + editGalleryAlbum, editTeamMember, links, createLink, diff --git a/app/utils/common.ts b/app/utils/common.ts index c46b8b1..d08c877 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -46,6 +46,20 @@ export const statusFilterOptions = [ export const errorMessage = 'Something went wrong. Please try again. '; +export function getReadableFileSize(bytes: number | null | undefined): string { + if (!bytes || bytes <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / (1024 ** exponent); + // Show one decimal place for KB and larger, none for bytes + return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`; +} + interface ServerError { field: string; messages: string | null; diff --git a/app/views/Galleries/GalleryForm/index.tsx b/app/views/Galleries/GalleryForm/index.tsx new file mode 100644 index 0000000..3e07939 --- /dev/null +++ b/app/views/Galleries/GalleryForm/index.tsx @@ -0,0 +1,411 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useParams } from 'react-router'; +import { + CloseLineIcon, + UploadLineIcon, +} from '@ifrc-go/icons'; +import { + BlockLoading, + Button, + Container, + IconButton, + InputSection, + ListView, + RawFileInput, + TextInput, +} from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + type ObjectSchema, + type PartialForm, + removeNull, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import { + type GalleryAlbumCreateInput, + type GalleryAlbumUpdateInput, + useCreateGalleryAlbumMutation, + useCreateGalleryImageMutation, + useDeleteGalleryImageMutation, + useGalleryAlbumDetailQuery, + useGalleryImageListQuery, + useUpdateGalleryAlbumMutation, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useRouting from '#hooks/useRouting'; +import { + errorMessage, + getReadableFileSize, +} from '#utils/common'; + +import styles from './styles.module.css'; + +type PartialFormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const GalleryAlbumSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + }), +}; + +const defaultEditFormValue: PartialFormType = {}; + +// Maximum number of images to load for an album in the edit view. +const MAX_IMAGES = 100; + +interface ExistingImage { + id: string; + name: string; + size: number; +} + +function GalleryForm() { + const { id } = useParams(); + const navigate = useRouting(); + const alert = useAlert(); + + const [newFiles, setNewFiles] = useState([]); + const [removedImageIds, setRemovedImageIds] = useState([]); + const [submitting, setSubmitting] = useState(false); + + const { + setFieldValue, + error: formError, + value, + validate, + setError, + setValue, + } = useForm(GalleryAlbumSchema, { value: defaultEditFormValue }); + + const [{ data, fetching: albumDetailFetch }] = useGalleryAlbumDetailQuery({ + variables: { id: (id ?? '') }, pause: !id, + }); + + const [{ data: imagesData, fetching: imagesFetch }] = useGalleryImageListQuery({ + variables: { filters: { albumId: id ?? '' }, offset: 0, limit: MAX_IMAGES }, + pause: !id, + }); + + const [{ fetching: createGalleryPending }, createAlbum] = useCreateGalleryAlbumMutation(); + const [{ fetching: updateGalleryPending }, updateAlbum] = useUpdateGalleryAlbumMutation(); + const [, addImagesToGallery] = useCreateGalleryImageMutation(); + const [, deleteImagesFromGallery] = useDeleteGalleryImageMutation(); + + const albumData = data?.galleryAlbum; + + useEffect(() => { + if (!albumDetailFetch && isDefined(albumData)) { + setValue(removeNull({ + title: albumData.title, + })); + } + }, [albumDetailFetch, albumData, setValue]); + + const existingImages: ExistingImage[] = useMemo(() => ( + (imagesData?.galleryImages.results ?? []) + .filter((image) => !removedImageIds.includes(image.id)) + .map((image) => ({ + id: image.id, + name: image.image.name, + size: image.image.size, + })) + ), [imagesData, removedImageIds]); + + const handleFilesSelect = useCallback((files: File[] | undefined) => { + if (files && files.length > 0) { + setNewFiles((prev) => [...prev, ...files]); + } + }, []); + + const handleRemoveExistingImage = useCallback((imageId: string) => { + setRemovedImageIds((prev) => [...prev, imageId]); + }, []); + + const handleRemoveNewFile = useCallback((index: number) => { + setNewFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); + }, []); + + const handleGalleryImages = useCallback(async (albumId: string) => { + const deleteResponses = await Promise.all( + removedImageIds.map((imageId) => deleteImagesFromGallery({ id: imageId })), + ); + const deleteFailed = deleteResponses.some( + (res) => !res.data?.deleteGalleryImage?.ok, + ); + + const uploadResponses = await Promise.all( + newFiles.map((file, index) => addImagesToGallery({ + data: { + album: albumId, + image: file, + order: existingImages.length + index, + }, + })), + ); + const uploadFailed = uploadResponses.some( + (res) => !res.data?.createGalleryImage?.ok, + ); + + const uploadedImageIds = uploadResponses + .map((res) => res.data?.createGalleryImage?.result?.id) + .filter(isDefined); + const coverImageId = existingImages[0]?.id ?? uploadedImageIds[0]; + + let coverFailed = false; + if (coverImageId) { + const coverRes = await updateAlbum({ + id: albumId, + data: { coverImage: coverImageId }, + }); + coverFailed = !coverRes.data?.updateGalleryAlbum?.ok; + } + + return deleteFailed || uploadFailed || coverFailed; + }, [ + updateAlbum, + addImagesToGallery, + deleteImagesFromGallery, + removedImageIds, + existingImages, + newFiles, + ]); + + const handleCreateGallery = useCallback(async (formData: PartialFormType) => { + setSubmitting(true); + try { + const res = await createAlbum({ + data: formData as GalleryAlbumCreateInput, + }); + const result = res.data?.createGalleryAlbum; + if (!result?.ok || !result.result?.id) { + if (result?.errors) { + setError(result.errors); + } + alert.show(result?.errors ?? errorMessage, { variant: 'danger' }); + return; + } + + const imagesFailed = await handleGalleryImages(result.result.id); + if (imagesFailed) { + alert.show( + 'Gallery saved, but some images could not be updated. Please review and try again.', + { variant: 'warning' }, + ); + } else { + alert.show('Gallery created successfully', { variant: 'success' }); + } + navigate('galleries'); + } catch { + alert.show(errorMessage, { variant: 'danger' }); + } finally { + setSubmitting(false); + } + }, [ + alert, + navigate, + setError, + createAlbum, + handleGalleryImages, + ]); + + const handleUpdateGallery = useCallback(async (formData: PartialFormType) => { + if (!id) { + return; + } + setSubmitting(true); + try { + const res = await updateAlbum({ + id, + data: formData as GalleryAlbumUpdateInput, + }); + const result = res.data?.updateGalleryAlbum; + if (!result?.ok) { + if (result?.errors) { + setError(result.errors); + } + alert.show(result?.errors ?? errorMessage, { variant: 'danger' }); + return; + } + + const imagesFailed = await handleGalleryImages(id); + if (imagesFailed) { + alert.show( + 'Gallery saved, but some images could not be updated. Please review and try again.', + { variant: 'warning' }, + ); + } else { + alert.show('Gallery updated successfully', { variant: 'success' }); + } + navigate('galleries'); + } catch { + alert.show(errorMessage, { variant: 'danger' }); + } finally { + setSubmitting(false); + } + }, [ + id, + alert, + navigate, + setError, + updateAlbum, + handleGalleryImages, + ]); + + const handleFormSubmit = useCallback( + () => createSubmitHandler( + validate, + setError, + isDefined(id) ? handleUpdateGallery : handleCreateGallery, + )(), + [validate, setError, id, handleUpdateGallery, handleCreateGallery], + ); + + const handleCancelClick = useCallback(() => { + navigate('galleries'); + }, [navigate]); + + const error = getErrorObject(formError); + + if (albumDetailFetch || imagesFetch) { + return ( + + ); + } + + return ( + + + + + )} + > + + + + + +
+ } + disabled={submitting} + > + Upload + +
+ {existingImages.map((image) => ( +
+
+
+ {image.name} +
+
+ {getReadableFileSize(image.size)} +
+
+ + + +
+ ))} + {newFiles.map((file, index) => ( +
+
+
+ {file.name} +
+
+ {getReadableFileSize(file.size)} + {file.size >= (2 * 1024 * 1024) && ( + + File size limit exceeded. File should not be + larger than 2MB. + + )} +
+
+ + + +
+ ))} +
+
+
+
+
+ ); +} + +export default GalleryForm; diff --git a/app/views/Galleries/GalleryForm/styles.module.css b/app/views/Galleries/GalleryForm/styles.module.css new file mode 100644 index 0000000..33e9357 --- /dev/null +++ b/app/views/Galleries/GalleryForm/styles.module.css @@ -0,0 +1,48 @@ +.content { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: var(--go-ui-spacing-md); +} + +.file-list { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-sm); + width: 100%; +} + +.file-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--go-ui-spacing-md); + border: var(--go-ui-width-separator-sm) solid var(--go-ui-color-gray-30); + border-radius: var(--go-ui-border-radius-md); + padding: var(--go-ui-spacing-sm) var(--go-ui-spacing-md); + + .file-info { + display: flex; + flex-direction: column; + overflow: hidden; + + .file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--go-ui-font-weight-bold); + } + + .file-size { + display: flex; + flex-direction: column; + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-sm); + + .error-message { + display: block; + color: var(--go-ui-color-negative); + } + } + } +} diff --git a/app/views/Galleries/index.tsx b/app/views/Galleries/index.tsx index f640c32..9bf4628 100644 --- a/app/views/Galleries/index.tsx +++ b/app/views/Galleries/index.tsx @@ -1,8 +1,250 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + AddFillIcon, + DeleteBinLineIcon, + EditTwoLineIcon, + SearchLineIcon, +} from '@ifrc-go/icons'; +import { + Button, + Container, + DateInput, + IconButton, + Pager, + Table, + TextInput, +} from '@ifrc-go/ui'; +import { + createActionColumn, + createDateColumn, + createNumberColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; + +import { + type GalleryAlbumFilter, + type GalleryAlbumListQuery, + useDeleteGalleryAlbumMutation, + useGalleryAlbumListQuery, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRouting from '#hooks/useRouting'; +import { + errorMessage, + idSelector, +} from '#utils/common'; + +type GalleryAlbumListItem = NonNullable['results'][number] & { no: number }>; + +interface GalleryAlbumsFilterType extends Omit { + createdAtGte: string | undefined; + createdAtLte: string | undefined; +} + +const defaultFilter: GalleryAlbumsFilterType = { + search: undefined, + createdAtGte: undefined, + createdAtLte: undefined, +}; + function Galleries() { + const { + filter, + rawFilter, + filtered, + setFilterField, + resetFilter, + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: defaultFilter, + }); + + const alert = useAlert(); + const navigate = useRouting(); + + const queryVariables = useMemo(() => ({ + offset, + limit, + filters: { + search: filter.search, + createdAt: (filter.createdAtGte || filter.createdAtLte) ? { + gte: filter.createdAtGte, + lte: filter.createdAtLte, + } : undefined, + }, + }), [limit, offset, filter]); + + const [{ fetching, data }, reExecuteQuery] = useGalleryAlbumListQuery({ + variables: queryVariables, + }); + const [, deleteGalleryAlbum] = useDeleteGalleryAlbumMutation(); + + const handleEditClick = useCallback((albumId: string) => { + navigate('editGalleryAlbum', { id: albumId }); + }, [navigate]); + + const handleCreateClick = useCallback(() => { + navigate('createGalleryAlbum'); + }, [navigate]); + + const onDeleteClick = useCallback( + (albumId: string) => { + deleteGalleryAlbum({ id: albumId }).then((resp) => { + const result = resp.data?.deleteGalleryAlbum; + if (result?.ok) { + reExecuteQuery(); + alert.show('Gallery album deleted successfully', { variant: 'success' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); + } + }).catch(() => { + alert.show(errorMessage, { variant: 'danger' }); + }); + }, + [deleteGalleryAlbum, reExecuteQuery, alert], + ); + + const tableData = useMemo(() => ( + data?.galleryAlbums.results.map((album, index) => { + const no = (page - 1) * limit + index + 1; + return { + ...album, + no, + }; + }) + ), [page, data, limit]); + + const columns = useMemo(() => [ + createNumberColumn( + 'no', + 'No.', + (album) => album.no, + ), + createDateColumn( + 'createdAt', + 'Created At', + (album) => album.createdAt, + ), + createStringColumn( + 'title', + 'Title', + (album) => album.title, + ), + createNumberColumn( + 'imagesCount', + '# of Images', + (album) => album.imagesCount, + ), + createStringColumn( + 'createdBy', + 'Created By', + (album) => album.createdBy.fullName, + ), + createDateColumn( + 'updatedAt', + 'Updated At', + (album) => album.updatedAt, + ), + createActionColumn( + 'action', + (item) => ({ + children: ( + <> + + + + + + + + ), + }), + ), + ], [handleEditClick, onDeleteClick]); + return ( -
- Galleries -
+ + + + } + variant="general" + /> + + + )} + footerActions={( + + )} + headerActions={( + + )} + > + + ); } diff --git a/app/views/Galleries/query.ts b/app/views/Galleries/query.ts new file mode 100644 index 0000000..45dea4c --- /dev/null +++ b/app/views/Galleries/query.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { gql } from 'urql'; + +const GALLERY_ALBUMS = gql` + query GalleryAlbumList( + $filters: GalleryAlbumFilter, + $offset: Int!, + $limit: Int + ) { + galleryAlbums ( + filters: $filters, + pagination: { + offset: $offset, + limit: $limit + } + ){ + totalCount + results { + id + title + updatedAt + imagesCount + createdAt + createdBy { + id + fullName + } + } + } + } +`; + +const GALLERY_ALBUM_DETAIL = gql` + query GalleryAlbumDetail($id: ID!) { + galleryAlbum(id: $id) { + id + title + } + } +`; + +const GALLERY_IMAGES = gql` + query GalleryImageList( + $filters: GalleryImageFilter, + $offset: Int!, + $limit: Int + ) { + galleryImages ( + filters: $filters, + order: { order: ASC }, + pagination: { + offset: $offset, + limit: $limit + } + ){ + totalCount + results { + id + albumId + caption + order + image { + name + size + url + } + } + } + } +`; + +const CREATE_GALLERY_IMAGE = gql` + mutation CreateGalleryImage($data: GalleryImageCreateInput!) { + createGalleryImage(data: $data) { + ... on GalleryImageTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; + +const DELETE_GALLERY_IMAGE = gql` + mutation DeleteGalleryImage($id: ID!) { + deleteGalleryImage(id: $id) { + ... on GalleryImageTypeMutationResponseType { + errors + ok + } + } + } +`; + +const CREATE_GALLERY_ALBUM = gql` + mutation CreateGalleryAlbum($data: GalleryAlbumCreateInput!) { + createGalleryAlbum(data: $data) { + ... on GalleryAlbumTypeMutationResponseType { + errors + ok + result { + id + } + } + } + } +`; + +const UPDATE_GALLERY_ALBUM = gql` + mutation UpdateGalleryAlbum($id: ID!, $data: GalleryAlbumUpdateInput!) { + updateGalleryAlbum(id: $id, data: $data) { + ... on GalleryAlbumTypeMutationResponseType { + errors + ok + } + } + } +`; + +const DELETE_GALLERY_ALBUM = gql` + mutation DeleteGalleryAlbum($id: ID!) { + deleteGalleryAlbum(id: $id) { + ... on GalleryAlbumTypeMutationResponseType { + errors + ok + result { + id + title + } + } + } + } +`;