Merge pull request #149 from sambecker/add-public-downloads
Add optional public downloads
This commit is contained in:
commit
9ec1c2c252
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
35
src/components/DownloadButton.tsx
Normal file
35
src/components/DownloadButton.tsx
Normal 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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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,12 +250,14 @@ export default function PhotoLarge({
|
||||
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
|
||||
)}
|
||||
/>
|
||||
<div className={clsx(
|
||||
'flex gap-1 translate-y-[0.5px]',
|
||||
ALLOW_PUBLIC_DOWNLOADS
|
||||
? 'translate-x-[-3px]'
|
||||
: 'md:translate-x-[-3px]',
|
||||
)}>
|
||||
{shouldShare &&
|
||||
<ShareButton
|
||||
className={clsx(
|
||||
'md:translate-x-[-2.5px]',
|
||||
'translate-y-[1.5px] md:translate-y-0',
|
||||
)}
|
||||
path={pathForPhotoShare({
|
||||
photo,
|
||||
tag: shouldShareTag ? primaryTag : undefined,
|
||||
@ -261,6 +270,14 @@ export default function PhotoLarge({
|
||||
prefetch={prefetchRelatedLinks}
|
||||
shouldScroll={shouldScrollOnShare}
|
||||
/>}
|
||||
{ALLOW_PUBLIC_DOWNLOADS &&
|
||||
<DownloadButton
|
||||
className={clsx(
|
||||
'translate-y-[0.5px] md:translate-y-0',
|
||||
)}
|
||||
photo={photo}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DivDebugBaselineGrid>}
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user