Merge pull request #149 from sambecker/add-public-downloads

Add optional public downloads
This commit is contained in:
Sam Becker 2024-09-21 16:23:51 -05:00 committed by GitHub
commit 9ec1c2c252
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 131 additions and 38 deletions

View File

@ -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)

View File

@ -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',

View File

@ -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 (
<LoaderButton
title="Download Original File"
className={clsx(
className,
'text-medium',
)}
icon={<MdOutlineFileDownload size={18} />}
spinnerColor='dim'
styleAs='link'
isLoading={isLoading}
onClick={async () => {
setIsLoading(true);
downloadFileFromBrowser(photo.url, downloadFileNameForPhoto(photo))
.finally(() => setIsLoading(false));
}}
/>
);
}

View File

@ -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={<TbPhotoShare size={16} />}
spinnerColor="dim"

View File

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

View File

@ -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({
/>}
</>}
<div className={clsx(
'flex gap-x-2 gap-y-baseline',
'md:flex-col md:justify-normal',
'flex gap-x-2.5 gap-y-baseline',
ALLOW_PUBLIC_DOWNLOADS
? 'flex-col'
: 'md:flex-col',
'md:justify-normal',
)}>
<PhotoDate
photo={photo}
@ -243,24 +250,34 @@ export default function PhotoLarge({
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
)}
/>
{shouldShare &&
<ShareButton
className={clsx(
'md:translate-x-[-2.5px]',
'translate-y-[1.5px] md:translate-y-0',
)}
path={pathForPhotoShare({
photo,
tag: shouldShareTag ? primaryTag : undefined,
camera: shouldShareCamera ? camera : undefined,
// eslint-disable-next-line max-len
simulation: shouldShareSimulation ? photo.filmSimulation : undefined,
// eslint-disable-next-line max-len
focal: shouldShareFocalLength ? photo.focalLength : undefined,
})}
prefetch={prefetchRelatedLinks}
shouldScroll={shouldScrollOnShare}
/>}
<div className={clsx(
'flex gap-1 translate-y-[0.5px]',
ALLOW_PUBLIC_DOWNLOADS
? 'translate-x-[-3px]'
: 'md:translate-x-[-3px]',
)}>
{shouldShare &&
<ShareButton
path={pathForPhotoShare({
photo,
tag: shouldShareTag ? primaryTag : undefined,
camera: shouldShareCamera ? camera : undefined,
// eslint-disable-next-line max-len
simulation: shouldShareSimulation ? photo.filmSimulation : undefined,
// eslint-disable-next-line max-len
focal: shouldShareFocalLength ? photo.focalLength : undefined,
})}
prefetch={prefetchRelatedLinks}
shouldScroll={shouldScrollOnShare}
/>}
{ALLOW_PUBLIC_DOWNLOADS &&
<DownloadButton
className={clsx(
'translate-y-[0.5px] md:translate-y-0',
)}
photo={photo}
/>}
</div>
</div>
</div>
</DivDebugBaselineGrid>}

View File

@ -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'));

View File

@ -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'])}
</ChecklistRow>
<ChecklistRow
title="Priority order"
status={isPriorityOrderEnabled}
title="Show repo link"
status={showRepoLink}
optional
>
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'])}
</ChecklistRow>
<ChecklistRow
title="Public downloads"
status={arePublicDownloadsEnabled}
optional
>
Set environment variable to {'"1"'} to enable
public photo downloads for all visitors:
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
</ChecklistRow>
<ChecklistRow
title="Public API"
@ -525,12 +534,13 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
title="Priority order"
status={isPriorityOrderEnabled}
optional
>
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'])}
</ChecklistRow>
<ChecklistRow
title="Show social"

View File

@ -147,6 +147,8 @@ export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const PUBLIC_API_ENABLED =
process.env.NEXT_PUBLIC_PUBLIC_API === '1';
export const ALLOW_PUBLIC_DOWNLOADS =
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
export const SHOW_REPO_LINK =
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
export const SHOW_SOCIAL =
@ -223,6 +225,7 @@ export const CONFIG_CHECKLIST_STATUS = {
Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS),
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isPublicApiEnabled: PUBLIC_API_ENABLED,
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
gridAspectRatio: GRID_ASPECT_RATIO,
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),

View File

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