Skip to content

Commit

Permalink
chore: added download feat
Browse files Browse the repository at this point in the history
  • Loading branch information
Bendomey committed Mar 3, 2025
1 parent 6aebfbc commit 1022fef
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 58 deletions.
38 changes: 38 additions & 0 deletions apps/client/app/api/contents/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<ApiResponse<ContentMedia>>(
`/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<unknown>,
contentId: string,
Expand Down
21 changes: 21 additions & 0 deletions apps/client/app/api/image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 16 additions & 9 deletions apps/client/app/components/Content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -195,15 +196,21 @@ export const Content = ({ content, showCreator = true, className }: Props) => {

<div className="flex justify-end">
{content.amount === 0 || isContentMine ? (
<Button
variant="outlined"
onClick={(e) => {
e.preventDefault()
}}
>
<ArrowDownTrayIcon className="mr-2 h-4 w-4" />
Download
</Button>
<DownloadButtonApi content={content}>
{({ isDisabled, onClick }) => (
<Button
disabled={isDisabled}
variant="outlined"
onClick={(e) => {
e.preventDefault()
onClick('MEDIUM')
}}
>
<ArrowDownTrayIcon className="mr-2 h-4 w-4" />
Download
</Button>
)}
</DownloadButtonApi>
) : (
<Button
variant={content.amount === 0 ? 'outlined' : 'solid'}
Expand Down
92 changes: 92 additions & 0 deletions apps/client/app/components/download-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useFetcher } from '@remix-run/react'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { QUERY_KEYS } from '@/constants/index.ts'
import { errorToast } from '@/lib/custom-toast-functions.tsx'

export type ContentSize = 'SMALL' | 'MEDIUM' | 'LARGE' | 'ORIGINAL'

interface Props {
content: Content
children: (props: {
isDisabled: boolean
onClick: (size: ContentSize) => void
}) => React.ReactNode
}

export function DownloadButtonApi({ children, content }: Props) {
const [isInitiatingDownload, setInitiatingDownload] = useState(false)
const queryClient = useQueryClient()
const fetcher = useFetcher<{
error?: string
signedUrl?: string
size?: string
}>()

// where there is an error in the action data, show an error toast
useEffect(() => {
if (fetcher.state === 'idle' && fetcher?.data?.error) {
setInitiatingDownload(false)
errorToast(fetcher?.data.error, {
id: 'error-downloading-content',
})
}
}, [fetcher?.data, fetcher.state])

const initiateDownload = useCallback(
async (signedUrl: string) => {
const fileResponse = await fetch(signedUrl)
const blob = await fileResponse.blob()

const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.setAttribute(
'download',
`${content.title.replace(/ /g, '_')}_${fetcher.data
?.size}`.toLowerCase(),
)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(link.href)
setInitiatingDownload(false)
},
[content.title, fetcher.data?.size],
)

useEffect(() => {
if (fetcher.state === 'idle' && fetcher.data?.signedUrl) {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.CONTENTS, content.slug],
})

initiateDownload(fetcher.data.signedUrl)
}
}, [
content.slug,
content.title,
fetcher.data,
fetcher.state,
initiateDownload,
queryClient,
])

const handleSubmit = (size: ContentSize) => {
setInitiatingDownload(true)
fetcher.submit(
{
contentId: content.id,
size,
},
{
action: `/api/download-content`,
encType: 'multipart/form-data',
method: 'post',
preventScrollReset: true,
},
)
}

const isDisabled = fetcher.state === 'submitting' || isInitiatingDownload
return children({ isDisabled, onClick: handleSubmit })
}
137 changes: 89 additions & 48 deletions apps/client/app/modules/photo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@ import { Image } from 'remix-image'
import { EditTitleModal } from './components/edit-title-modal/index.tsx'
import { RelatedContent } from './components/related-content.tsx'
import { Button } from '@/components/button/index.tsx'
import {
type ContentSize,
DownloadButtonApi,
} from '@/components/download-button.tsx'
import { Footer } from '@/components/footer/index.tsx'
import { Header } from '@/components/layout/index.ts'
import { LikeButton } from '@/components/like-button.tsx'
import { ShareButton } from '@/components/share-button/index.tsx'
import { UserImage } from '@/components/user-image.tsx'
import { blurDataURL, PAGES } from '@/constants/index.ts'
import { useDisclosure } from '@/hooks/use-disclosure.tsx'
import { classNames } from '@/lib/classNames.ts'
import { convertPesewasToCedis, formatAmount } from '@/lib/format-amount.ts'
import { getSizeStringForContent } from '@/lib/image-fns.ts'
import { safeString } from '@/lib/strings.ts'
Expand Down Expand Up @@ -100,7 +105,10 @@ export const PhotoModule = () => {
)}
</div>
{isContentMine || content.amount === 0 ? (
<DownloadButton sizes={content.media.sizes} />
<DownloadButton
content={content as unknown as Content}
sizes={content.media.sizes}
/>
) : (
<Button color="success">
<LockClosedIcon className="mr-1 size-4 text-white" />
Expand Down Expand Up @@ -257,71 +265,104 @@ export const PhotoModule = () => {

interface Props {
sizes: ContentMediaSizes
content: Content
}

export default function DownloadButton({ sizes }: Props) {
export default function DownloadButton({ sizes, content }: Props) {
const items = [
{
name: 'Small',
size: getSizeStringForContent(sizes.small),
disabled: Boolean(sizes.small),
isAvailable: Boolean(sizes.small),
},
{
name: 'Medium',
size: getSizeStringForContent(sizes.medium),
disabled: Boolean(sizes.medium),
isAvailable: Boolean(sizes.medium),
},
{
name: 'Large',
size: getSizeStringForContent(sizes.large),
disabled: Boolean(sizes.large),
isAvailable: Boolean(sizes.large),
},
]

return (
<div className="inline-flex rounded-md shadow-sm">
<button
type="button"
className="relative inline-flex items-center rounded-l-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-800 focus:z-10"
>
Download
</button>
<Menu as="div" className="relative -ml-px block">
<MenuButton className="relative inline-flex items-center rounded-r-md border-l border-gray-200 bg-green-600 px-2 py-2 text-white hover:bg-green-800 focus:z-10">
<span className="sr-only">Open options</span>
<ChevronDownIcon aria-hidden="true" className="size-5" />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 -mr-1 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 transition focus:outline-none data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
<div className="py-1">
{items.map((item) => (
<Fragment key={item.name}>
{item.disabled ? (
<MenuItem>
<button className="flex w-full px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:text-gray-900 data-[focus]:outline-none">
{item.name}{' '}
<span className="ml-1 text-xs text-gray-400">
({item.size})
</span>
</button>
</MenuItem>
) : null}
</Fragment>
))}
<DownloadButtonApi content={content}>
{({ isDisabled, onClick }) => (
<div className="inline-flex rounded-md shadow-sm">
<button
disabled={isDisabled}
onClick={() => onClick('MEDIUM')}
type="button"
className={classNames(
'relative inline-flex items-center rounded-l-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-800 focus:z-10',
{
'cursor-not-allowed bg-green-800/50 hover:bg-green-800/50':
isDisabled,
},
)}
>
Download
</button>
<Menu as="div" className="relative -ml-px block">
<MenuButton
disabled={isDisabled}
className={classNames(
'relative inline-flex items-center rounded-r-md border-l border-gray-200 bg-green-600 px-2 py-2 text-white hover:bg-green-800 focus:z-10',
{
'cursor-not-allowed bg-green-800/50 hover:bg-green-800/50':
isDisabled,
},
)}
>
<span className="sr-only">Open options</span>
<ChevronDownIcon aria-hidden="true" className="size-5" />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 -mr-1 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 transition focus:outline-none data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
<div className="py-1">
{items.map((item) => (
<Fragment key={item.name}>
{item.isAvailable ? (
<MenuItem>
<button
onClick={() => {
onClick(item.name.toUpperCase() as ContentSize)
}}
disabled={isDisabled}
className="flex w-full px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:text-gray-900 data-[focus]:outline-none"
>
{item.name}{' '}
<span className="ml-1 text-xs text-gray-400">
({item.size})
</span>
</button>
</MenuItem>
) : null}
</Fragment>
))}

<MenuItem>
<button className="flex w-full items-center px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:text-gray-900 data-[focus]:outline-none">
Original{' '}
<span className="ml-1 text-xs text-gray-400">
({getSizeStringForContent(sizes.original)})
</span>
</button>
</MenuItem>
</div>
</MenuItems>
</Menu>
</div>
<MenuItem>
<button
disabled={isDisabled}
onClick={() => onClick('ORIGINAL')}
type="button"
className="flex w-full items-center px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:text-gray-900 data-[focus]:outline-none"
>
Original{' '}
<span className="ml-1 text-xs text-gray-400">
({getSizeStringForContent(sizes.original)})
</span>
</button>
</MenuItem>
</div>
</MenuItems>
</Menu>
</div>
)}
</DownloadButtonApi>
)
}
Loading

0 comments on commit 1022fef

Please sign in to comment.