diff --git a/apps/client/app/api/contents/index.ts b/apps/client/app/api/contents/index.ts index 546ed0e..1bd1ae8 100644 --- a/apps/client/app/api/contents/index.ts +++ b/apps/client/app/api/contents/index.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { type ContentSize } from '@/components/download-button.tsx' import { QUERY_KEYS } from '@/constants/index.ts' import { getQueryParams } from '@/lib/get-param.ts' import { safeString } from '@/lib/strings.ts' @@ -110,6 +111,43 @@ export const unlikeContent = async ( } } +interface DownloadContentInput { + contentId: string + size: ContentSize +} + +export const downloadContent = async ( + downloadContentInput: DownloadContentInput, + apiConfig: ApiConfigForServerConfig, +) => { + try { + const response = await fetchClient>( + `/v1/contents/${downloadContentInput.contentId}/download`, + { + method: 'POST', + body: JSON.stringify({ size: downloadContentInput.size }), + ...apiConfig, + }, + ) + + if (!response.parsedBody.status && response.parsedBody.errorMessage) { + throw new Error(response.parsedBody.errorMessage) + } + + return response.parsedBody.data + } catch (error: unknown) { + if (error instanceof Error) { + throw error + } + + // Error from server. + if (error instanceof Response) { + const response = await error.json() + throw new Error(response.errorMessage) + } + } +} + export const getContentLikes = async ( query: FetchMultipleDataInputParams, contentId: string, diff --git a/apps/client/app/api/image/index.ts b/apps/client/app/api/image/index.ts index fc2a454..ab0e149 100644 --- a/apps/client/app/api/image/index.ts +++ b/apps/client/app/api/image/index.ts @@ -32,3 +32,24 @@ export const useSignS3UploadUrl = () => useMutation({ mutationFn: generateSignedUrl, }) + +interface IGenerateDownloadSignedUrlInput { + key: string +} + +export const generateDownloadSignedUrl = async ( + props: IGenerateDownloadSignedUrlInput, + origin: string, +) => { + const res = await fetch(`${origin}/api/s3/download`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + key: props.key, + }), + }) + const data = await res.json() + return data as IGenerateSignedUrlOutput +} diff --git a/apps/client/app/components/Content/index.tsx b/apps/client/app/components/Content/index.tsx index 4286ee3..ec5a4e0 100644 --- a/apps/client/app/components/Content/index.tsx +++ b/apps/client/app/components/Content/index.tsx @@ -13,6 +13,7 @@ import { Link, useNavigate } from '@remix-run/react' import { Image } from 'remix-image' import { Button } from '../button/index.tsx' import { PhotographerCreatorCard } from '../creator-card/index.tsx' +import { DownloadButtonApi } from '../download-button.tsx' import { FlyoutContainer } from '../flyout/flyout-container.tsx' import { LikeButton } from '@/components/like-button.tsx' import { blurDataURL, PAGES } from '@/constants/index.ts' @@ -195,15 +196,21 @@ export const Content = ({ content, showCreator = true, className }: Props) => {
{content.amount === 0 || isContentMine ? ( - + + {({ isDisabled, onClick }) => ( + + )} + ) : (
{isContentMine || content.amount === 0 ? ( - + ) : ( - - - Open options - - -
- {items.map((item) => ( - - {item.disabled ? ( - - - - ) : null} - - ))} + + {({ isDisabled, onClick }) => ( +
+ + + + Open options + + +
+ {items.map((item) => ( + + {item.isAvailable ? ( + + + + ) : null} + + ))} - - - -
-
-
-
+ + + +
+
+
+ + )} + ) } diff --git a/apps/client/app/routes/api.download-content.ts b/apps/client/app/routes/api.download-content.ts new file mode 100644 index 0000000..4057328 --- /dev/null +++ b/apps/client/app/routes/api.download-content.ts @@ -0,0 +1,47 @@ +import { type ActionFunctionArgs } from '@remix-run/node' +import { downloadContent } from '@/api/contents/index.ts' +import { generateDownloadSignedUrl } from '@/api/image/index.ts' +import { type ContentSize } from '@/components/download-button.tsx' +import { environmentVariables } from '@/lib/actions/env.server.ts' +import { extractAuthCookie } from '@/lib/actions/extract-auth-cookie.ts' +import { getDomainUrl } from '@/lib/misc.ts' + +export async function action({ request }: ActionFunctionArgs) { + const baseUrl = `${environmentVariables().API_ADDRESS}/api` + + const formData = await request.formData() + const contentId = formData.get('contentId') + const size = formData.get('size') + + if (!contentId || !size) { + return { error: 'Invalid request' } + } + + const authCookie = await extractAuthCookie(request.headers.get('cookie')) + + try { + const response = await downloadContent( + { + contentId: contentId as string, + size: size as ContentSize, + }, + { + authToken: authCookie ? authCookie.token : undefined, + baseUrl, + }, + ) + + if (response) { + const downloadSignedUrl = await generateDownloadSignedUrl( + { + key: response?.key, + }, + getDomainUrl(request), + ) + + return { success: true, signedUrl: downloadSignedUrl.signedUrl, size } + } + } catch { + return { error: 'Downloading image failed. Try again!' } + } +} diff --git a/apps/client/server/routes/s3.ts b/apps/client/server/routes/s3.ts index 21484bc..358727e 100644 --- a/apps/client/server/routes/s3.ts +++ b/apps/client/server/routes/s3.ts @@ -1,4 +1,8 @@ -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' +import { + S3Client, + PutObjectCommand, + GetObjectCommand, +} from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import * as express from 'express' @@ -37,4 +41,21 @@ s3Router.post( }, ) +// download the file +s3Router.post( + '/download', + async (req: express.Request<{}, {}, { key: string }>, res) => { + const key = req.body.key + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + ResponseContentDisposition: `attachment; filename="${key}"`, + }) + const signedUrl = await getSignedUrl(s3Client, command, { + expiresIn: 5 * 60, + }) + return res.json({ signedUrl }) + }, +) + export { s3Router } diff --git a/apps/client/types/content.d.ts b/apps/client/types/content.d.ts index c56e58e..29874fc 100644 --- a/apps/client/types/content.d.ts +++ b/apps/client/types/content.d.ts @@ -41,6 +41,16 @@ interface ContentMediaSizes { original: number } +interface ContentMedia { + location: string + orientation: string + size: number + eTag: string + key: string + serverSideEncryption: string + bucket: string +} + interface ImageProcessingResponse { status: IRekognitionMetaDataStatus message?: string