diff --git a/README.md b/README.md index 95ffb8a9..56ad8d1f 100644 --- a/README.md +++ b/README.md @@ -110,9 +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_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/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 new file mode 100644 index 00000000..a2874c4f --- /dev/null +++ b/src/components/DownloadButton.tsx @@ -0,0 +1,35 @@ +import { MdOutlineFileDownload } from 'react-icons/md'; +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, + className, +}: { + photo: Photo + className?: string +}) { + const [isLoading, setIsLoading] = useState(false); + + return ( + } + spinnerColor='dim' + styleAs='link' + isLoading={isLoading} + onClick={async () => { + setIsLoading(true); + downloadFileFromBrowser(photo.url, downloadFileNameForPhoto(photo)) + .finally(() => setIsLoading(false)); + }} + /> + ); +} 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/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/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index fb87af1d..15fc2c29 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -19,13 +19,17 @@ 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'; import { sortTags } from '@/tag'; import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid'; import PhotoLink from './PhotoLink'; -import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; +import { + SHOULD_PREFETCH_ALL_LINKS, + ALLOW_PUBLIC_DOWNLOADS, +} from '@/site/config'; import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient'; import { RevalidatePhoto } from './InfinitePhotoScroll'; import { useRef } from 'react'; @@ -232,8 +236,11 @@ export default function PhotoLarge({ />} }
- {shouldShare && - } +
+ {shouldShare && + } + {ALLOW_PUBLIC_DOWNLOADS && + } +
} 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')); diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index f1d4772a..d2883f9f 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -64,6 +64,7 @@ export default function SiteChecklistClient({ aiTextAutoGeneratedFields, hasAiTextAutoGeneratedFields, isPublicApiEnabled, + arePublicDownloadsEnabled, isOgTextBottomAligned, gridAspectRatio, hasGridAspectRatio, @@ -507,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 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'])} 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); +};