From cf446b29e328284f810325d922379ff85c5602c5 Mon Sep 17 00:00:00 2001 From: si1k <19499950+si1k@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:14:38 -0400 Subject: [PATCH 1/6] Adding public download button option --- README.md | 1 + src/components/DownloadButton.tsx | 46 +++++++++++++++++++ .../primitives/PathLoaderButton.tsx | 14 ++++-- src/photo/PhotoLarge.tsx | 10 ++++ src/site/SiteChecklistClient.tsx | 10 ++++ src/site/config.ts | 3 ++ 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/components/DownloadButton.tsx diff --git a/README.md b/README.md index eeec7040..58fdfce6 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_HIDE_TITLE_FALLBACK_TEXT = 1` prevents showing "Untitled" for photos without titles - `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order - `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` +- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public image downloads - `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo - `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X button from share modal - `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx new file mode 100644 index 00000000..8219bf4e --- /dev/null +++ b/src/components/DownloadButton.tsx @@ -0,0 +1,46 @@ +import { MdOutlineFileDownload } from 'react-icons/md'; +import PathLoaderButton from './primitives/PathLoaderButton'; +import { clsx } from 'clsx/lite'; +import { Photo } from '@/photo'; + +export default function DownloadButton({ + photo, + dim, + className, +}: { + photo: Photo + dim?: boolean + className?: string +}) { + const {url, title} = photo; + + return ( + } + spinnerColor='dim' + styleAs='link' + shouldReplace + handleAction={async () => { + const response = await fetch(url); + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = title + ? title.replace(/[^a-z0-9]/gi, '_').toLowerCase() + : url.split('/').pop() || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + }} + /> + ); +} diff --git a/src/components/primitives/PathLoaderButton.tsx b/src/components/primitives/PathLoaderButton.tsx index 27a4efa8..79c1af06 100644 --- a/src/components/primitives/PathLoaderButton.tsx +++ b/src/components/primitives/PathLoaderButton.tsx @@ -10,6 +10,7 @@ export default function PathLoaderButton({ loaderDelay = 100, shouldScroll = true, shouldReplace, + handleAction, children, ...props }: { @@ -18,6 +19,7 @@ export default function PathLoaderButton({ loaderDelay?: number shouldScroll?: boolean shouldReplace?: boolean + handleAction?: () => Promise } & ComponentProps) { const router = useRouter(); @@ -46,11 +48,15 @@ export default function PathLoaderButton({ { - startTransition(() => { - if (shouldReplace) { - router.replace(path, { scroll: shouldScroll }); + startTransition(async () => { + if (handleAction) { + await handleAction(); } else { - router.push(path, { scroll: shouldScroll }); + if (shouldReplace) { + router.replace(path, { scroll: shouldScroll }); + } else { + router.push(path, { scroll: shouldScroll }); + } } }); }} diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index cda6ac5b..be19eb82 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -19,6 +19,7 @@ import { } from '@/site/paths'; import PhotoTags from '@/tag/PhotoTags'; import ShareButton from '@/components/ShareButton'; +import DownloadButton from '@/components/DownloadButton'; import PhotoCamera from '../camera/PhotoCamera'; import { cameraFromPhoto } from '@/camera'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; @@ -28,6 +29,7 @@ import PhotoLink from './PhotoLink'; import { SHOULD_PREFETCH_ALL_LINKS, SHOW_PHOTO_TITLE_FALLBACK_TEXT, + ALLOW_PUBLIC_DOWNLOADS, } from '@/site/config'; import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient'; import { RevalidatePhoto } from './InfinitePhotoScroll'; @@ -228,6 +230,14 @@ export default function PhotoLarge({ !hasNonDateContent && isUserSignedIn && 'md:pr-7', )} /> + {ALLOW_PUBLIC_DOWNLOADS && + } {shouldShare && /api: {renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])} + + Set environment variable to {'"1"'} to enable + public downloads of photos: + {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} + Date: Sat, 21 Sep 2024 15:13:16 -0500 Subject: [PATCH 2/6] Tweak public download config text --- src/site/SiteChecklistClient.tsx | 6 +++--- src/site/config.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index f438c051..9a2cc43b 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -64,7 +64,7 @@ export default function SiteChecklistClient({ aiTextAutoGeneratedFields, hasAiTextAutoGeneratedFields, isPublicApiEnabled, - isPublicDownloadsEnabled, + arePublicDownloadsEnabled, isOgTextBottomAligned, gridAspectRatio, hasGridAspectRatio, @@ -527,11 +527,11 @@ export default function SiteChecklistClient({ Set environment variable to {'"1"'} to enable - public downloads of photos: + public photo downloads: {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} Date: Sat, 21 Sep 2024 15:26:11 -0500 Subject: [PATCH 3/6] Refine file download behavior --- src/admin/AdminPhotoMenuClient.tsx | 8 ++++-- src/components/DownloadButton.tsx | 27 ++++++++++--------- .../primitives/PathLoaderButton.tsx | 14 +++------- src/photo/index.ts | 6 +++++ 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx index dc0ceda6..e73f0629 100644 --- a/src/admin/AdminPhotoMenuClient.tsx +++ b/src/admin/AdminPhotoMenuClient.tsx @@ -4,7 +4,11 @@ import { ComponentProps, useMemo } from 'react'; import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths'; import { deletePhotoAction, toggleFavoritePhotoAction } from '@/photo/actions'; import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa'; -import { Photo, deleteConfirmationTextForPhoto } from '@/photo'; +import { + Photo, + deleteConfirmationTextForPhoto, + downloadFileNameForPhoto, +} from '@/photo'; import { isPathFavs, isPhotoFav } from '@/tag'; import { usePathname } from 'next/navigation'; import { BiTrash } from 'react-icons/bi'; @@ -64,7 +68,7 @@ export default function AdminPhotoMenuClient({ className="translate-x-[-1.5px] translate-y-[-0.5px]" />, href: photo.url, - hrefDownloadName: photo.url.split('/').pop(), + hrefDownloadName: downloadFileNameForPhoto(photo), }); items.push({ label: 'Delete', diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index 8219bf4e..e7b88b8f 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -1,7 +1,8 @@ import { MdOutlineFileDownload } from 'react-icons/md'; -import PathLoaderButton from './primitives/PathLoaderButton'; import { clsx } from 'clsx/lite'; -import { Photo } from '@/photo'; +import { downloadFileNameForPhoto, Photo } from '@/photo'; +import LoaderButton from './primitives/LoaderButton'; +import { useState } from 'react'; export default function DownloadButton({ photo, @@ -12,30 +13,30 @@ export default function DownloadButton({ dim?: boolean className?: string }) { - const {url, title} = photo; + const [isLoading, setIsLoading] = useState(false); return ( - } + icon={} spinnerColor='dim' styleAs='link' - shouldReplace - handleAction={async () => { - const response = await fetch(url); - const blob = await response.blob(); + isLoading={isLoading} + onClick={async () => { + setIsLoading(true); + const blob = await fetch(photo.url) + .then(response => response.blob()) + .finally(() => setIsLoading(false)); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; - link.download = title - ? title.replace(/[^a-z0-9]/gi, '_').toLowerCase() - : url.split('/').pop() || 'download'; + link.download = downloadFileNameForPhoto(photo); document.body.appendChild(link); link.click(); document.body.removeChild(link); diff --git a/src/components/primitives/PathLoaderButton.tsx b/src/components/primitives/PathLoaderButton.tsx index 79c1af06..27a4efa8 100644 --- a/src/components/primitives/PathLoaderButton.tsx +++ b/src/components/primitives/PathLoaderButton.tsx @@ -10,7 +10,6 @@ export default function PathLoaderButton({ loaderDelay = 100, shouldScroll = true, shouldReplace, - handleAction, children, ...props }: { @@ -19,7 +18,6 @@ export default function PathLoaderButton({ loaderDelay?: number shouldScroll?: boolean shouldReplace?: boolean - handleAction?: () => Promise } & ComponentProps) { const router = useRouter(); @@ -48,15 +46,11 @@ export default function PathLoaderButton({ { - startTransition(async () => { - if (handleAction) { - await handleAction(); + startTransition(() => { + if (shouldReplace) { + router.replace(path, { scroll: shouldScroll }); } else { - if (shouldReplace) { - router.replace(path, { scroll: shouldScroll }); - } else { - router.push(path, { scroll: shouldScroll }); - } + router.push(path, { scroll: shouldScroll }); } }); }} diff --git a/src/photo/index.ts b/src/photo/index.ts index e3890a20..97c96b71 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -12,6 +12,7 @@ import { formatExposureCompensation, formatExposureTime, } from '@/utility/exif'; +import { parameterize } from '@/utility/string'; import camelcaseKeys from 'camelcase-keys'; import { isBefore } from 'date-fns'; import type { Metadata } from 'next'; @@ -311,5 +312,10 @@ export const isNextImageReadyBasedOnPhotos = async (photos: Photo[]) => .then(response => response.ok) .catch(() => false); +export const downloadFileNameForPhoto = (photo: Photo) => + photo.title + ? `${parameterize(photo.title)}.${photo.extension}` + : photo.url.split('/').pop() || 'download'; + export const doesPhotoNeedBlurCompatibility = (photo: Photo) => isBefore(photo.updatedAt, new Date('2024-05-07')); From 17b999f8d338bcab4cf0d3ef23ba2e33c67ba629 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 21 Sep 2024 15:55:54 -0500 Subject: [PATCH 4/6] Create universal file download logic --- src/components/DownloadButton.tsx | 16 +++------------- src/components/more/MoreMenuItem.tsx | 7 +++++-- src/utility/url.ts | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index e7b88b8f..26cd4256 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -3,14 +3,13 @@ import { clsx } from 'clsx/lite'; import { downloadFileNameForPhoto, Photo } from '@/photo'; import LoaderButton from './primitives/LoaderButton'; import { useState } from 'react'; +import { downloadFileFromBrowser } from '@/utility/url'; export default function DownloadButton({ photo, - dim, className, }: { photo: Photo - dim?: boolean className?: string }) { const [isLoading, setIsLoading] = useState(false); @@ -20,7 +19,7 @@ export default function DownloadButton({ title="Download Original File" className={clsx( className, - dim ? 'text-dim' : 'text-medium', + 'text-medium', '-mx-0.5 translate-x-0.5', 'sm:mx-0 sm:translate-x-0' )} @@ -30,17 +29,8 @@ export default function DownloadButton({ isLoading={isLoading} onClick={async () => { setIsLoading(true); - const blob = await fetch(photo.url) - .then(response => response.blob()) + downloadFileFromBrowser(photo.url, downloadFileNameForPhoto(photo)) .finally(() => setIsLoading(false)); - const downloadUrl = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = downloadUrl; - link.download = downloadFileNameForPhoto(photo); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(downloadUrl); }} /> ); diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index 0de107a1..2530bd1c 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -5,6 +5,7 @@ import { clsx } from 'clsx/lite'; import { ReactNode, useState, useTransition } from 'react'; import LoaderButton from '../primitives/LoaderButton'; import { usePathname, useRouter } from 'next/navigation'; +import { downloadFileFromBrowser } from '@/utility/url'; export default function MoreMenuItem({ label, @@ -53,8 +54,10 @@ export default function MoreMenuItem({ } } if (href && href !== pathname) { - if (Boolean(hrefDownloadName)) { - window.open(href, '_blank'); + if (hrefDownloadName) { + setIsLoading(true); + downloadFileFromBrowser(href, hrefDownloadName) + .finally(() => setIsLoading(false)); } else { startTransition(() => router.push(href)); } diff --git a/src/utility/url.ts b/src/utility/url.ts index 71b2e887..c41dc8e7 100644 --- a/src/utility/url.ts +++ b/src/utility/url.ts @@ -10,3 +10,19 @@ export const makeUrlAbsolute = (url?: string) => url !== undefined ? (!url.startsWith('http') ? `https://${url}` : url) .replace(/\/$/, '') : undefined; + +export const downloadFileFromBrowser = async ( + url: string, + fileName: string, +) => { + const blob = await fetch(url) + .then(response => response.blob()); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); +}; From 13e5be229625a299859ebd413e9c6a84dd56d9a8 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 21 Sep 2024 16:11:02 -0500 Subject: [PATCH 5/6] Refine public download button layout --- src/components/DownloadButton.tsx | 2 - src/components/ShareButton.tsx | 2 - src/photo/PhotoLarge.tsx | 61 +++++++++++++++++-------------- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index 26cd4256..a2874c4f 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -20,8 +20,6 @@ export default function DownloadButton({ className={clsx( className, 'text-medium', - '-mx-0.5 translate-x-0.5', - 'sm:mx-0 sm:translate-x-0' )} icon={} spinnerColor='dim' diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 25875e55..bb9d5d33 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -21,8 +21,6 @@ export default function ShareButton({ className={clsx( className, dim ? 'text-dim' : 'text-medium', - '-mx-0.5 translate-x-0.5', - 'sm:mx-0 sm:translate-x-0', )} icon={} spinnerColor="dim" diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index fef27e9f..15fc2c29 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -236,8 +236,11 @@ export default function PhotoLarge({ />} }
- {ALLOW_PUBLIC_DOWNLOADS && - } - {shouldShare && - } +
+ {shouldShare && + } + {ALLOW_PUBLIC_DOWNLOADS && + } +
} From b354cc31370429ae24153ee36229ffa98f1002c6 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 21 Sep 2024 16:16:16 -0500 Subject: [PATCH 6/6] Update public download README text --- README.md | 6 +++--- src/site/SiteChecklistClient.tsx | 36 ++++++++++++++++---------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bca2ef50..56ad8d1f 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,10 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_MATTE_PHOTOS = 1` constrains the size of each photo, and enables a surrounding border (potentially useful for photos with tall aspect ratios) - `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage) - `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information) -- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order -- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` -- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public image downloads - `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo +- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage) +- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` +- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order - `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X button from share modal - `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results - `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography) diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 9a2cc43b..d2883f9f 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -508,13 +508,21 @@ export default function SiteChecklistClient({ {renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
- Set environment variable to {'"1"'} to prevent - priority order photo field affecting photo order: - {renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])} + Set environment variable to {'"1"'} to hide footer link: + {renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])} + + + Set environment variable to {'"1"'} to enable + public photo downloads for all visitors: + {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} - Set environment variable to {'"1"'} to enable - public photo downloads: - {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} - - - Set environment variable to {'"1"'} to hide footer link: - {renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])} + Set environment variable to {'"1"'} to prevent + priority order photo field affecting photo order: + {renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}