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);
+};