This commit is contained in:
carlobortolan 2025-01-25 17:50:45 +01:00
commit cd7b49042b
No known key found for this signature in database
GPG Key ID: 574D9F10F0EED1BE
44 changed files with 1578 additions and 1095 deletions

View File

@ -96,27 +96,34 @@ _⚠ READ BEFORE PROCEEDING_
Application behavior can be changed by configuring the following environment variables:
#### Site meta
#### Content
- `NEXT_PUBLIC_SITE_TITLE` (seen in browser tab)
- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, beneath title)
- `NEXT_PUBLIC_SITE_ABOUT` (seen in grid sidebar—accepts rich formatting tags: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
#### Site behavior
#### Performance
> ⚠️ Enabling may result in increased project usage
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1` enables static optimization for photo pages (`p/[photoId]`), i.e., renders pages at build time
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1` enables static optimization for OG images, i.e., renders images at build time
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES = 1` enables static optimization for photo categories (`tag/[tag]`, `shot-on/[make]/[model]`, etc.), i.e., renders pages at build time
- `NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS = 1` prevents photo uploads being compressed before storing
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
#### Display
- `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)
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `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_REPO_LINK = 1` removes footer link to repo
#### Settings
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
- `NEXT_PUBLIC_DEFAULT_THEME = light | dark` sets preferred initial theme (defaults to `system` when not configured)
- `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage (results in increased storage usage)
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES = 1` enables static optimization for pages, i.e., renders pages at build time (results in increased project usage)—⚠️ _Experimental_
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES = 1` enables static optimization for OG images, i.e., renders images at build time (results in increased project usage)—⚠️ _Experimental_
- `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_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)
- `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint)
- `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
@ -152,7 +159,7 @@ Only one storage adapter—Vercel Blob, Cloudflare R2, or AWS S3—can be used a
- Store public configuration:
- `NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET`: bucket name
- `NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID`: account id (found on R2 overview page)
- `NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN`: either "your-custom-domain.com" or "pub-jf90908...s0d9f8s0s9df.r2.dev" (_do not include "https://" in your domain_)
- `NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN`: either "your-custom-domain.com" or "pub-jf90908...s0d9f8s0s9df.r2.dev"
2. Setup private credentials
- Create API token by selecting "Manage R2 API Tokens," and clicking "Create API Token"
- Select "Object Read & Write," choose "Apply to specific buckets only," and select the bucket created in Step 1
@ -233,7 +240,7 @@ FAQ
> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or use the outdated photo page (`/admin/outdated`) to make batch updates.
#### Why dont my OG images load when I share a link?
> Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES = 1` and `NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES = 1`. Keep in mind that this will increase platform usage.
> Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1` and `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1`. Keep in mind that this will increase platform usage.
#### Why do vertical images take up so much space?
> By default, all photos are shown full-width, regardless of orientation. Enable matting to showcase horizontal and vertical photos at similar scales by setting `NEXT_PUBLIC_MATTE_PHOTOS = 1`.

View File

@ -1,6 +1,9 @@
/* eslint-disable max-len */
import {
convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString,
validatePostgresDateString,
validateNaivePostgresDateString,
} from '../src/utility/date';
describe('Date utility', () => {
@ -29,19 +32,34 @@ describe('Date utility', () => {
expect(convertTimestampToNaivePostgresString(timestamp))
.toBe('2023-12-02 16:38:36');
});
it('Malformed date string', () => {
const timestamp = '2024/01a/01 Z';
expect(convertTimestampWithOffsetToPostgresString(timestamp))
.toBe(convertTimestampWithOffsetToPostgresString(
new Date().toISOString(),
));
});
it('Empty string', () => {
const timestamp = ' ';
expect(convertTimestampWithOffsetToPostgresString(timestamp))
.toBe(convertTimestampWithOffsetToPostgresString(
new Date().toISOString(),
));
});
});
it('Malformed date string', () => {
const timestamp = '2024/01a/01 Z';
expect(convertTimestampWithOffsetToPostgresString(timestamp))
.toBe(convertTimestampWithOffsetToPostgresString(
new Date().toISOString(),
));
});
it('Empty string', () => {
const timestamp = ' ';
expect(convertTimestampWithOffsetToPostgresString(timestamp))
.toBe(convertTimestampWithOffsetToPostgresString(
new Date().toISOString(),
));
describe('validates date strings', () => {
it('Correct', () => {
expect(validatePostgresDateString('2025-01-03T21:00:44.000Z')).toBe(true);
expect(validateNaivePostgresDateString('2025-01-03 16:00:44')).toBe(true);
});
it('Incorrect', () => {
expect(validatePostgresDateString('2024-01-01')).toBe(false);
expect(validatePostgresDateString('2025-01-03 16:00:44')).toBe(false);
expect(validateNaivePostgresDateString('2024-01-01')).toBe(false);
expect(validatePostgresDateString('2025-01-03T21:00:44.000')).toBe(false);
expect(validateNaivePostgresDateString('2025-01-03T16:00:44')).toBe(false);
expect(validatePostgresDateString('2025-01-03T21:00:44.000ZZ')).toBe(false);
expect(validateNaivePostgresDateString('2025-01-03 16:00:44Z')).toBe(false);
});
});
});

View File

@ -1,5 +1,4 @@
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
import '@testing-library/jest-dom';
import { makeUrlAbsolute, removeUrlProtocol, shortenUrl } from '@/utility/url';
const URL_LONG_1 = 'https://www.example.com/';
const URL_LONG_2 = 'https://www.example.com';
@ -12,9 +11,10 @@ const URL_LONG_7 = 'https://example.com/final-path/';
const URL_SHORT_1 = 'example.com';
const URL_SHORT_2 = 'example.com/';
const URL_SHORT_3 = 'example.com/final-path';
const URL_SHORT_4 = 'www.example.com';
describe('String', () => {
it('url can be shortened', () => {
describe('URL', () => {
it('can be shortened', () => {
expect(shortenUrl(URL_LONG_1)).toBe(URL_SHORT_1);
expect(shortenUrl(URL_LONG_2)).toBe(URL_SHORT_1);
expect(shortenUrl(URL_LONG_3)).toBe(URL_SHORT_1);
@ -23,7 +23,15 @@ describe('String', () => {
expect(shortenUrl(URL_LONG_6)).toBe(URL_SHORT_3);
expect(shortenUrl(URL_LONG_7)).toBe(URL_SHORT_3);
});
it('url can be made absolute', () => {
it('can have protocol removed', () => {
expect(removeUrlProtocol(URL_LONG_1)).toBe(URL_SHORT_4);
expect(removeUrlProtocol(URL_LONG_2)).toBe(URL_SHORT_4);
expect(removeUrlProtocol(URL_LONG_4)).toBe(URL_SHORT_1);
expect(removeUrlProtocol(URL_LONG_5)).toBe(URL_SHORT_1);
expect(removeUrlProtocol(URL_LONG_6)).toBe(URL_SHORT_3);
expect(removeUrlProtocol(URL_LONG_7)).toBe(URL_SHORT_3);
});
it('can be made absolute', () => {
expect(makeUrlAbsolute(URL_SHORT_1)).toBe(URL_LONG_5);
expect(makeUrlAbsolute(URL_SHORT_2)).toBe(URL_LONG_5);
});

View File

@ -14,6 +14,7 @@ const eslintConfig = [
rules: {
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-unused-expressions': ['warn'],
'@typescript-eslint/no-unused-vars': [
'warn', {

View File

@ -1,3 +1,7 @@
import { removeUrlProtocol } from '@/utility/url';
import type { NextConfig } from 'next';
import { RemotePattern } from 'next/dist/shared/lib/image-config';
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
)?.[1].toLowerCase();
@ -16,23 +20,30 @@ const HOSTNAME_AWS_S3 =
? `${process.env.NEXT_PUBLIC_AWS_S3_BUCKET}.s3.${process.env.NEXT_PUBLIC_AWS_S3_REGION}.amazonaws.com`
: undefined;
const createRemotePattern = (hostname) => hostname
? {
const generateRemotePattern = (hostname: string) =>
({
protocol: 'https',
hostname,
hostname: removeUrlProtocol(hostname)!,
port: '',
pathname: '/**',
}
: [];
} as const);
/** @type {import('next').NextConfig} */
const nextConfig = {
const remotePatterns: RemotePattern[] = [];
if (HOSTNAME_VERCEL_BLOB) {
remotePatterns.push(generateRemotePattern(HOSTNAME_VERCEL_BLOB));
}
if (HOSTNAME_CLOUDFLARE_R2) {
remotePatterns.push(generateRemotePattern(HOSTNAME_CLOUDFLARE_R2));
}
if (HOSTNAME_AWS_S3) {
remotePatterns.push(generateRemotePattern(HOSTNAME_AWS_S3));
}
const nextConfig: NextConfig = {
images: {
imageSizes: [200],
remotePatterns: []
.concat(createRemotePattern(HOSTNAME_VERCEL_BLOB))
.concat(createRemotePattern(HOSTNAME_CLOUDFLARE_R2))
.concat(createRemotePattern(HOSTNAME_AWS_S3)),
remotePatterns,
minimumCacheTTL: 31536000,
},
};

View File

@ -9,26 +9,26 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.18",
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/s3-request-presigner": "3.726.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@ai-sdk/openai": "^1.1.1",
"@aws-sdk/client-s3": "3.733.0",
"@aws-sdk/s3-request-presigner": "3.733.0",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-visually-hidden": "^1.1.1",
"@upstash/ratelimit": "^2.0.5",
"@vercel/analytics": "^1.4.1",
"@vercel/blob": "^0.27.0",
"@vercel/blob": "^0.27.1",
"@vercel/kv": "^3.0.0",
"@vercel/speed-insights": "^1.1.0",
"ai": "^4.0.33",
"ai": "^4.1.1",
"camelcase-keys": "^9.1.3",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"exifr": "^7.1.3",
"framer-motion": "^11.17.0",
"framer-motion": "^12.0.1",
"nanoid": "^5.0.9",
"next": "15.1.4",
"next": "15.1.6",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
"pg": "^8.13.1",
@ -37,31 +37,31 @@
"react-icons": "^5.4.0",
"sanitize-html": "^2.14.0",
"sharp": "^0.33.5",
"sonner": "^1.7.1",
"sonner": "^1.7.2",
"swr": "^2.3.0",
"ts-exif-parser": "^0.2.2",
"use-debounce": "^10.0.4",
"viewerjs": "^1.11.7"
},
"devDependencies": {
"@next/bundle-analyzer": "15.1.4",
"@next/bundle-analyzer": "15.1.6",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.5",
"@types/node": "^22.10.7",
"@types/pg": "^8.11.10",
"@types/react": "19.0.4",
"@types/react-dom": "19.0.2",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"@types/sanitize-html": "^2.13.0",
"autoprefixer": "10.4.20",
"clsx": "^2.1.1",
"eslint": "9.18.0",
"eslint-config-next": "15.1.4",
"eslint-config-next": "15.1.6",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "8.4.49",
"postcss": "8.5.1",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
}

1296
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,10 @@
'use client';
import LinkWithLoader from '@/components/LinkWithLoader';
import LinkWithStatus from '@/components/LinkWithStatus';
import Note from '@/components/Note';
import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner';
import {
PATH_ADMIN_CONFIGURATION,
checkPathPrefix,
@ -11,7 +14,6 @@ import {
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { differenceInMinutes } from 'date-fns';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { BiCog } from 'react-icons/bi';
@ -60,40 +62,43 @@ export default function AdminNavClient({
contentMain={
<div className="space-y-5">
<div className={clsx(
'flex gap-2 md:gap-4',
'border-b border-gray-200 dark:border-gray-800 pb-3',
'flex gap-2 pb-3',
'border-b border-gray-200 dark:border-gray-800',
)}>
<div className={clsx(
'flex gap-2 md:gap-4',
'flex gap-0.5 md:gap-1.5 -mx-1',
'flex-grow overflow-x-auto',
)}>
{items.map(({ label, href, count }) =>
<Link
<LinkWithStatus
key={label}
href={href}
className={clsx(
'flex gap-0.5',
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
'px-1 py-0.5 rounded-md',
)}
loadingClassName="bg-dim"
prefetch={false}
>
<span>{label}</span>
{count > 0 &&
<span>({count})</span>}
</Link>)}
</LinkWithStatus>)}
</div>
<Link
<LinkWithLoader
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
loader={<Spinner />}
>
<BiCog
size={18}
className="inline-block"
className="inline-flex translate-y-0.5"
aria-label="App Configuration"
/>
</Link>
</LinkWithLoader>
</div>
{shouldShowBanner &&
<Note icon={<FaRegClock className="flex-shrink-0" />}>

View File

@ -3,7 +3,10 @@
import PhotoUpload from '@/photo/PhotoUpload';
import { clsx } from 'clsx/lite';
import SiteGrid from '@/components/SiteGrid';
import { AI_TEXT_GENERATION_ENABLED, PRO_MODE_ENABLED } from '@/site/config';
import {
AI_TEXT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/site/config';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
@ -43,7 +46,7 @@ export default function AdminPhotosClient({
<div className="flex">
<div className="grow min-w-0">
<PhotoUpload
shouldResize={!PRO_MODE_ENABLED}
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
isUploading={isUploading}
setIsUploading={setIsUploading}
onLastUpload={onLastPhotoUpload}

View File

@ -8,6 +8,7 @@ import {
AI_TEXT_GENERATION_ENABLED,
BLUR_ENABLED,
} from '@/site/config';
import ErrorNote from '@/components/ErrorNote';
export const maxDuration = 60;
@ -23,16 +24,19 @@ export default async function UploadPage({ params }: Params) {
photoFormExif,
imageResizedBase64: imageThumbnailBase64,
shouldStripGpsData,
error,
} = await extractImageDataFromBlobPath(uploadPath, {
includeInitialPhotoFields: true,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
});
if (
const isDataMissing =
!photoFormExif ||
(AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64)
) {
(AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64);
if (isDataMissing && !error) {
// Only redirect if there's no error to report
redirect(PATH_ADMIN);
}
@ -43,14 +47,18 @@ export default async function UploadPage({ params }: Params) {
const textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS;
return (
<UploadPageClient {...{
blobId,
photoFormExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
imageThumbnailBase64,
shouldStripGpsData,
}} />
!isDataMissing
? <UploadPageClient {...{
blobId,
photoFormExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
imageThumbnailBase64,
shouldStripGpsData,
}} />
: <ErrorNote>
{error ?? 'Unknown error'}
</ErrorNote>
);
};

View File

@ -1,13 +1,26 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueFilmSimulations } from '@/photo/db/query';
import { FilmSimulation, generateMetaForFilmSimulation } from '@/simulation';
import FilmSimulationOverview from '@/simulation/FilmSimulationOverview';
import { IS_PRODUCTION } from '@/site/config';
import { getPhotosFilmSimulationDataCached } from '@/simulation/data';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { Metadata } from 'next/types';
import { cache } from 'react';
const getPhotosFilmSimulationDataCachedCached =
cache(getPhotosFilmSimulationDataCached);
export let generateStaticParams:
(() => Promise<{ simulation: FilmSimulation }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => {
const simulations = await getUniqueFilmSimulations();
return simulations.map(({ simulation }) => ({ simulation }));
};
}
interface FilmSimulationProps {
params: Promise<{ simulation: FilmSimulation }>
}

View File

@ -2,6 +2,9 @@ import { generateMetaForFocalLength, getFocalLengthFromString } from '@/focal';
import FocalLengthOverview from '@/focal/FocalLengthOverview';
import { getPhotosFocalLengthDataCached } from '@/focal/data';
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { IS_PRODUCTION } from '@/site/config';
import { getUniqueFocalLengths } from '@/photo/db/query';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { PATH_ROOT } from '@/site/paths';
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
@ -13,6 +16,16 @@ const getPhotosFocalDataCachedCached = cache((focal: number) =>
limit: INFINITE_SCROLL_GRID_INITIAL,
}));
export let generateStaticParams:
(() => Promise<{ focal: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => {
const focalLengths = await getUniqueFocalLengths();
return focalLengths.map(({ focal }) => ({ focal: focal.toString() }));
};
}
interface FocalLengthProps {
params: Promise<{ focal: string }>
}

View File

@ -4,7 +4,10 @@ import PhotoImageResponse from '@/image-response/PhotoImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_OG_IMAGES } from '@/site/config';
import {
IS_PRODUCTION,
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES,
} from '@/site/config';
import { getPhotoIds } from '@/photo/db/query';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { isNextImageReadyBasedOnPhotos } from '@/photo';
@ -12,7 +15,7 @@ import { isNextImageReadyBasedOnPhotos } from '@/photo';
export let generateStaticParams:
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_OG_IMAGES && IS_PRODUCTION) {
if (STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES && IS_PRODUCTION) {
generateStaticParams = async () => {
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
return photos.map(photoId => ({ photoId }));

View File

@ -12,7 +12,7 @@ import {
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCached } from '@/photo/cache';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_PAGES } from '@/site/config';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_PHOTOS } from '@/site/config';
import { getPhotoIds } from '@/photo/db/query';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { cache } from 'react';
@ -25,7 +25,7 @@ const getPhotosNearIdCachedCached = cache((photoId: string) =>
export let generateStaticParams:
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PAGES && IS_PRODUCTION) {
if (STATICALLY_OPTIMIZED_PHOTOS && IS_PRODUCTION) {
generateStaticParams = async () => {
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
return photos.map(photoId => ({ photoId }));

View File

@ -5,6 +5,9 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getPhotosCameraDataCached } from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview';
import { cache } from 'react';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { IS_PRODUCTION } from '@/site/config';
import { getUniqueCameras } from '@/photo/db/query';
const getPhotosCameraDataCachedCached = cache((
make: string,
@ -15,6 +18,16 @@ const getPhotosCameraDataCachedCached = cache((
INFINITE_SCROLL_GRID_INITIAL,
));
export let generateStaticParams:
(() => Promise<{ make: string, model: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => {
const cameras = await getUniqueCameras();
return cameras.map(({ camera: { make, model } }) => ({ make, model }));
};
}
export async function generateMetadata({
params,
}: CameraProps): Promise<Metadata> {

View File

@ -1,4 +1,7 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueTags } from '@/photo/db/query';
import { IS_PRODUCTION } from '@/site/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { PATH_ROOT } from '@/site/paths';
import { generateMetaForTag } from '@/tag';
import TagOverview from '@/tag/TagOverview';
@ -10,6 +13,16 @@ import { cache } from 'react';
const getPhotosTagDataCachedCached = cache((tag: string) =>
getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL}));
export let generateStaticParams:
(() => Promise<{ tag: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => {
const tags = await getUniqueTags();
return tags.map(({ tag }) => ({ tag }));
};
}
interface TagProps {
params: Promise<{ tag: string }>
}

View File

@ -6,7 +6,6 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import {
useActionState,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
@ -28,8 +27,9 @@ export default function SignInForm() {
const [response, action] = useActionState(signInAction, undefined);
const emailRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
emailRef.current?.focus();
useEffect(() => {
const timeout = setTimeout(() => emailRef.current?.focus(), 100);
return () => clearTimeout(timeout);
}, []);
useEffect(() => {

View File

@ -6,6 +6,7 @@ import {
KEY_CREDENTIALS_SIGN_IN_ERROR,
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
auth,
generateAuthSecret,
signIn,
signOut,
} from '@/auth';
@ -47,3 +48,5 @@ export const getAuthAction = async () => auth();
export const logClientAuthUpdate = async (data: Session | null | undefined) =>
console.log('Client auth update', data);
export const generateAuthSecretAction = async () => generateAuthSecret();

View File

@ -0,0 +1,32 @@
import { BiCopy } from 'react-icons/bi';
import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast';
export default function CopyButton({
label,
text,
subtle,
}: {
label: string
text?: string,
subtle?: boolean
}) {
return (
<LoaderButton
icon={<BiCopy size={15} />}
className={clsx(
'translate-y-[2px]',
subtle && 'text-gray-300 dark:text-gray-700',
)}
onClick={text
? () => {
navigator.clipboard.writeText(text);
toastSuccess(`${label} copied to clipboard`);
}
: undefined}
styleAs="link"
disabled={!text}
/>
);
}

View File

@ -58,7 +58,7 @@ export default function FieldSetWithStatus({
{!hideLabel && label &&
<label
className={clsx(
'flex gap-2 items-center select-none',
'flex flex-wrap gap-x-2 items-center select-none',
type === 'checkbox' && 'order-2 pt-[3px]',
)}
htmlFor={id}

View File

@ -0,0 +1,31 @@
import { ComponentProps, ReactNode } from 'react';
import LinkWithStatus from './LinkWithStatus';
import clsx from 'clsx/lite';
import Link from 'next/link';
export default function LinkWithLoader({
loader,
children,
...props
}: ComponentProps<typeof Link> & {
loader: ReactNode
}) {
return (
<LinkWithStatus {...props}>
{({ isLoading }) => <>
<span className={clsx(
'flex transition-opacity',
isLoading ? 'opacity-0' : 'opacity-100',
)}>
{children}
</span>
{isLoading && <span className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>
{loader}
</span>}
</>}
</LinkWithStatus>
);
}

View File

@ -0,0 +1,122 @@
'use client';
import {
ComponentProps,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx/lite';
// Avoid showing spinner for too short a time
const FLICKER_THRESHOLD = 400;
// Clear loading status after long duration
const MAX_LOADING_DURATION = 15_000;
export type LinkWithStatusProps = Omit<
ComponentProps<typeof Link>, 'children'
> & {
loadingClassName?: string
children: ReactNode | ((props: {
isLoading: boolean
}) => ReactNode)
}
export default function LinkWithStatus({
loadingClassName,
href,
className,
onClick,
children,
...props
}: LinkWithStatusProps) {
const path = usePathname();
const [pathWhenClicked, setPathWhenClicked] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const isLoadingStartTime = useRef<number | undefined>(undefined);
const startLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const stopLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const maxLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const isControlled = typeof children === 'function';
const clearTimeouts = useCallback(() => {
[startLoadingTimeout, stopLoadingTimeout, maxLoadingTimeout]
.forEach(timeout => {
if (timeout.current) { clearTimeout(timeout.current); }
});
}, []);
const stopLoading = useCallback(() => {
setIsLoading(false);
setPathWhenClicked(undefined);
}, []);
const isVisitingLinkHref = path === href;
const shouldCancelLoading =
(pathWhenClicked && pathWhenClicked !== path) ||
isVisitingLinkHref;
useEffect(() => {
if (shouldCancelLoading) {
clearTimeouts();
const loadingDuration = isLoadingStartTime.current
? Date.now() - isLoadingStartTime.current
: 0;
if (loadingDuration < FLICKER_THRESHOLD) {
stopLoadingTimeout.current = setTimeout(
stopLoading,
FLICKER_THRESHOLD - loadingDuration,
);
} else {
stopLoading();
}
}
}, [shouldCancelLoading, clearTimeouts, stopLoading]);
// Clear timeouts when unmounting
useEffect(() => () => clearTimeouts(), [clearTimeouts]);
return <Link
{...props }
href={href}
className={clsx(
'relative flex transition-[colors,opacity]',
(loadingClassName || isControlled)
? 'opacity-100'
: isLoading ? 'opacity-50' : 'opacity-100',
className,
isLoading && loadingClassName,
)}
onClick={e => {
const isOpeningNewTab = e.metaKey || e.ctrlKey;
if (!isVisitingLinkHref && !isOpeningNewTab) {
setPathWhenClicked(path);
startLoadingTimeout.current = setTimeout(
() => {
isLoadingStartTime.current = Date.now();
setIsLoading(true);
},
FLICKER_THRESHOLD,
);
maxLoadingTimeout.current = setTimeout(
stopLoading,
MAX_LOADING_DURATION,
);
}
onClick?.(e);
}}
>
{typeof children === 'function'
? children({ isLoading })
: children}
</Link>;
}

View File

@ -10,11 +10,13 @@ export default function ResponsiveDate({
className,
titleLabel,
timezone: timezoneFromProps,
hideTime,
}: {
date: Date
className?: string
titleLabel?: string
timezone?: Timezone
hideTime?: boolean,
}) {
const [timezone, setTimezone] = useState(timezoneFromProps);
@ -24,23 +26,30 @@ export default function ResponsiveDate({
}
}, [timezoneFromProps]);
const showPlaceholderContent = timezone === undefined;
const showPlaceholder = timezone === undefined;
const titleDateFormatted = formatDate(date, undefined, timezone)
const titleDateFormatted = formatDate({ date, timezone })
.toLocaleUpperCase();
const title = titleLabel
? `${titleLabel}: ${titleDateFormatted}`
: titleDateFormatted;
const contentClass = showPlaceholderContent && 'opacity-0 select-none';
const contentClass = showPlaceholder && 'opacity-0 select-none';
const formatDateProps = {
date,
timezone,
showPlaceholder,
hideTime,
} as const;
return (
<span
title={showPlaceholderContent ? 'LOADING LOCAL TIME' : title}
title={showPlaceholder ? 'LOADING LOCAL TIME' : title}
className={clsx(
'uppercase rounded-md transition-colors',
showPlaceholderContent && 'bg-dim',
showPlaceholder && 'bg-dim',
className,
)}
>
@ -49,20 +58,20 @@ export default function ResponsiveDate({
className={clsx('xs:hidden', contentClass)}
aria-hidden
>
{formatDate(date, 'short', timezone, showPlaceholderContent)}
{formatDate({ ...formatDateProps, length: 'short' })}
</span>
{/* Medium */}
<span
className={clsx('hidden xs:inline-block sm:hidden', contentClass)}
aria-hidden
>
{formatDate(date, 'medium', timezone,showPlaceholderContent)}
{formatDate({ ...formatDateProps, length: 'medium' })}
</span>
{/* Large */}
<span
className={clsx('hidden sm:inline-block', contentClass)}
>
{formatDate(date, undefined, timezone, showPlaceholderContent)}
{formatDate(formatDateProps)}
</span>
</span>
);

View File

@ -1,7 +1,8 @@
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { JSX } from 'react';
import Spinner from './Spinner';
import LinkWithLoader from './LinkWithLoader';
export default function SwitcherItem({
icon,
@ -44,9 +45,15 @@ export default function SwitcherItem({
return (
href
? <Link {...{ title, href, className, prefetch }}>
? <LinkWithLoader {...{
title,
href,
className,
prefetch,
loader: <Spinner />,
}}>
{renderIcon()}
</Link>
</LinkWithLoader>
: <div {...{ title, onClick, className }}>{renderIcon()}</div>
);
};

View File

@ -8,11 +8,13 @@ export default function PhotoDate({
className,
dateType = 'takenAt',
timezone,
hideTime,
}: {
photo: Photo
className?: string
dateType?: 'takenAt' | 'createdAt' | 'updatedAt'
timezone: Timezone
hideTime?: boolean
}) {
const date = useMemo(() => {
const date = new Date(dateType === 'takenAt'
@ -45,6 +47,7 @@ export default function PhotoDate({
className,
titleLabel: getTitleLabel(),
timezone,
hideTime,
}} />
);
}

View File

@ -19,7 +19,7 @@ export default function PhotoEscapeHandler() {
useEffect(() => {
if (shouldRespondToKeyboardCommands) {
const onKeyUp = (e: KeyboardEvent) => {
if (e.key.toUpperCase() === 'ESCAPE' && escapePath) {
if (e.key?.toUpperCase() === 'ESCAPE' && escapePath) {
router.push(escapePath, { scroll: false });
};
};

View File

@ -9,6 +9,7 @@ import PhotoGridSidebar from './PhotoGridSidebar';
import PhotoGridContainer from './PhotoGridContainer';
import { useEffect } from 'react';
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
export default function PhotoGridPage({
photos,
@ -35,14 +36,19 @@ export default function PhotoGridPage({
cacheKey={`page-${PATH_GRID}`}
photos={photos}
count={photosCount}
sidebar={<div className="sticky top-4 space-y-4 mt-[-4px]">
<PhotoGridSidebar {...{
tags,
cameras,
simulations,
photosCount,
}} />
</div>}
sidebar={
<div className={clsx(
'sticky top-0 -mt-5',
'max-h-screen overflow-y-auto py-4',
'[scrollbar-width:none]',
)}>
<PhotoGridSidebar {...{
tags,
cameras,
simulations,
photosCount,
}} />
</div>}
canSelect
/>
);

View File

@ -44,7 +44,7 @@ export default function PhotoGridSidebar({
, [tags, hiddenPhotosCount]);
return (
<>
<div className="space-y-4">
{SITE_ABOUT && <HeaderList
items={[<p
key="about"
@ -143,6 +143,6 @@ export default function PhotoGridSidebar({
: <HeaderList
items={[photoQuantityText(photosCount, false)]}
/>}
</>
</div>
);
}

View File

@ -28,6 +28,7 @@ import PhotoLink from './PhotoLink';
import {
SHOULD_PREFETCH_ALL_LINKS,
ALLOW_PUBLIC_DOWNLOADS,
SHOW_TAKEN_AT_TIME,
} from '@/site/config';
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
import { RevalidatePhoto } from './InfinitePhotoScroll';
@ -251,8 +252,10 @@ export default function PhotoLarge({
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
)}
// Created at is a naive datetime which
// does not require a timezone
// does not require a timezone and will not
// cause server/client time mismatch
timezone={null}
hideTime={!SHOW_TAKEN_AT_TIME}
/>
<div className={clsx(
'flex gap-1 translate-y-[0.5px]',

View File

@ -7,12 +7,13 @@ import {
doesPhotoNeedBlurCompatibility,
} from '.';
import ImageMedium from '@/components/image/ImageMedium';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
import LinkWithStatus from '@/components/LinkWithStatus';
import Spinner from '@/components/Spinner';
export default function PhotoMedium({
photo,
@ -38,7 +39,7 @@ export default function PhotoMedium({
useOnVisible(ref, onVisible);
return (
<Link
<LinkWithStatus
ref={ref}
href={pathForPhoto({ photo, tag, camera, simulation, focal })}
className={clsx(
@ -48,16 +49,28 @@ export default function PhotoMedium({
)}
prefetch={prefetch}
>
<ImageMedium
src={photo.url}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
className="flex object-cover w-full h-full"
imgClassName="object-cover w-full h-full"
alt={altTextForPhoto(photo)}
priority={priority}
/>
</Link>
{({ isLoading }) =>
<div>
{isLoading &&
<div className={clsx(
'absolute inset-0 flex items-center justify-center',
'text-white bg-black/25 backdrop-blur-sm',
'animate-fade-in',
'z-10',
)}>
<Spinner size={20} color="text" />
</div>}
<ImageMedium
src={photo.url}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
className="flex object-cover w-full h-full "
imgClassName="object-cover w-full h-full"
alt={altTextForPhoto(photo)}
priority={priority}
/>
</div>}
</LinkWithStatus>
);
};

View File

@ -5,6 +5,8 @@ import {
convertTimestampWithOffsetToPostgresString,
generateLocalNaivePostgresString,
generateLocalPostgresString,
validationMessageNaivePostgresDateString,
validationMessagePostgresDateString,
} from '@/utility/date';
import {
convertApertureValueToFNumber,
@ -116,8 +118,14 @@ const FORM_METADATA = (
locationName: { label: 'location name', hide: true },
latitude: { label: 'latitude' },
longitude: { label: 'longitude' },
takenAt: { label: 'taken at' },
takenAtNaive: { label: 'taken at (naive)' },
takenAt: {
label: 'taken at',
validate: validationMessagePostgresDateString,
},
takenAtNaive: {
label: 'taken at (naive)',
validate: validationMessageNaivePostgresDateString,
},
priorityOrder: { label: 'priority order' },
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
hidden: { label: 'hidden', type: 'checkbox' },

View File

@ -27,13 +27,13 @@ export const INFINITE_SCROLL_FEED_MULTIPLE =
// INFINITE SCROLL: GRID
export const INFINITE_SCROLL_GRID_INITIAL = HIGH_DENSITY_GRID
? process.env.NODE_ENV === 'development' ? 12 : 24
: process.env.NODE_ENV === 'development' ? 12 : 24;
? process.env.NODE_ENV === 'development' ? 12 : 48
: process.env.NODE_ENV === 'development' ? 12 : 48;
export const INFINITE_SCROLL_GRID_MULTIPLE = HIGH_DENSITY_GRID
? process.env.NODE_ENV === 'development' ? 12 : 48
: process.env.NODE_ENV === 'development' ? 12 : 48;
// Thumbnails below /p/[photoId]
// Thumbnails below large photos on pages like /p/[photoId]
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
export const DEFAULT_ASPECT_RATIO = 1.5;
@ -212,7 +212,10 @@ export const titleForPhoto = (
if (photo.title) {
return photo.title;
} else if (preferDateOverUntitled && (photo.takenAt || photo.createdAt)) {
return formatDate(photo.takenAt || photo.createdAt, 'tiny');
return formatDate({
date: photo.takenAt || photo.createdAt,
length: 'tiny',
});
} else {
return 'Untitled';
}

View File

@ -11,7 +11,7 @@ import { ExifData, ExifParserFactory } from 'ts-exif-parser';
import { PhotoFormData } from './form';
import { FilmSimulation } from '@/simulation';
import sharp, { Sharp } from 'sharp';
import { GEO_PRIVACY_ENABLED, PRO_MODE_ENABLED } from '@/site/config';
import { GEO_PRIVACY_ENABLED, PRESERVE_ORIGINAL_UPLOADS } from '@/site/config';
const IMAGE_WIDTH_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200;
@ -29,6 +29,7 @@ export const extractImageDataFromBlobPath = async (
imageResizedBase64?: string
shouldStripGpsData?: boolean
fileBytes?: ArrayBuffer
error?: string
}> => {
const {
includeInitialPhotoFields,
@ -42,49 +43,60 @@ export const extractImageDataFromBlobPath = async (
const extension = getExtensionFromStorageUrl(url);
const fileBytes = blobPath
? await fetch(url, { cache: 'no-store' }).then(res => res.arrayBuffer())
: undefined;
let exifData: ExifData | undefined;
let filmSimulation: FilmSimulation | undefined;
let blurData: string | undefined;
let imageResizedBase64: string | undefined;
let shouldStripGpsData = false;
let error: string | undefined;
if (fileBytes) {
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
const fileBytes = blobPath
? await fetch(url, { cache: 'no-store' }).then(res => res.arrayBuffer())
.catch(e => {
error = `Error fetching image from ${url}: "${e.message}"`;
return undefined;
})
: undefined;
// Data for form
parser.enableBinaryFields(false);
exifData = parser.parse();
try {
if (fileBytes) {
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
// Capture film simulation for Fujifilm cameras
if (isExifForFujifilm(exifData)) {
// Parse exif data again with binary fields
// in order to access MakerNote tag
parser.enableBinaryFields(true);
const exifDataBinary = parser.parse();
const makerNote = exifDataBinary.tags?.MakerNote;
if (Buffer.isBuffer(makerNote)) {
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
// Data for form
parser.enableBinaryFields(false);
exifData = parser.parse();
// Capture film simulation for Fujifilm cameras
if (isExifForFujifilm(exifData)) {
// Parse exif data again with binary fields
// in order to access MakerNote tag
parser.enableBinaryFields(true);
const exifDataBinary = parser.parse();
const makerNote = exifDataBinary.tags?.MakerNote;
if (Buffer.isBuffer(makerNote)) {
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
}
}
}
if (generateBlurData) {
blurData = await blurImage(fileBytes);
}
if (generateBlurData) {
blurData = await blurImage(fileBytes);
}
if (generateResizedImage) {
imageResizedBase64 = await resizeImage(fileBytes);
}
if (generateResizedImage) {
imageResizedBase64 = await resizeImage(fileBytes);
}
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
Boolean(exifData.tags?.GPSLatitude) ||
Boolean(exifData.tags?.GPSLongitude)
);
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
Boolean(exifData.tags?.GPSLatitude) ||
Boolean(exifData.tags?.GPSLongitude)
);
}
} catch (e) {
error = `Error extracting image data from ${url}: "${e}"`;
}
if (error) { console.log(error); }
return {
blobId,
...exifData && {
@ -102,6 +114,7 @@ export const extractImageDataFromBlobPath = async (
imageResizedBase64,
shouldStripGpsData,
fileBytes,
error,
};
};
@ -169,5 +182,5 @@ export const removeGpsData = async (image: ArrayBuffer) =>
GPSHPositioningError: GPS_NULL_STRING,
},
})
.toFormat('jpeg', { quality: PRO_MODE_ENABLED ? 95 : 80 })
.toFormat('jpeg', { quality: PRESERVE_ORIGINAL_UPLOADS ? 95 : 80 })
.toBuffer();

View File

@ -6,13 +6,14 @@ import {
CopyObjectCommand,
} from '@aws-sdk/client-s3';
import { StorageListResponse, generateStorageId } from '.';
import { removeUrlProtocol } from '@/utility/url';
const CLOUDFLARE_R2_BUCKET =
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? '';
const CLOUDFLARE_R2_ACCOUNT_ID =
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? '';
const CLOUDFLARE_R2_PUBLIC_DOMAIN =
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN ?? '';
removeUrlProtocol(process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN) ?? '';
const CLOUDFLARE_R2_ACCESS_KEY =
process.env.CLOUDFLARE_R2_ACCESS_KEY ?? '';
const CLOUDFLARE_R2_SECRET_ACCESS_KEY =

View File

@ -11,6 +11,7 @@ import { PiXLogo } from 'react-icons/pi';
import { SHOW_SOCIAL } from '@/site/config';
import { generateXPostText } from '@/utility/social';
import { useAppState } from '@/state/AppState';
import useOnPathChange from '@/utility/useOnPathChange';
export default function ShareModal({
title,
@ -44,6 +45,8 @@ export default function ShareModal({
{icon}
</div>;
useOnPathChange(() => setShareModalProps?.(undefined));
return (
<Modal onClose={() => setShareModalProps?.(undefined)}>
<div className="space-y-3 md:space-y-4 w-full">

View File

@ -0,0 +1,51 @@
'use client';
import { clsx } from 'clsx/lite';
import Container from '@/components/Container';
import Spinner from '@/components/Spinner';
import CopyButton from '@/components/CopyButton';
import { useCallback, useEffect, useState } from 'react';
import { generateAuthSecretAction } from '@/auth/actions';
import { BiRefresh } from 'react-icons/bi';
export default function SecretGenerator() {
const [isLoading, setIsLoading] = useState(false);
const [secret, setSecret] = useState('');
const getSecret = useCallback(async () => {
setIsLoading(true);
await generateAuthSecretAction()
.then(setSecret)
.finally(() => setIsLoading(false));
}, []);
useEffect(() => {
getSecret();
}, [getSecret]);
return (
<div className="flex items-center gap-2">
<Container className="my-1.5 inline-flex" padding="tight">
<div className={clsx(
'flex flex-nowrap items-center gap-2 leading-none -mx-1',
)}>
{secret ? <span>{secret}</span> : <Spinner />}
<div
className="flex items-center gap-0.5 translate-y-[-2px]"
>
<CopyButton label="Secret" text={secret} />
</div>
</div>
</Container>
{secret && <div className="flex items-center justify-center w-6">
{isLoading
? <Spinner />
: <BiRefresh
className="cursor-pointer active:translate-y-[1px] shrink-0"
onClick={getSecret}
size={18}
/>}
</div>}
</div>
);
}

View File

@ -9,26 +9,26 @@ import ChecklistRow from '../components/ChecklistRow';
import { FiExternalLink } from 'react-icons/fi';
import {
BiCog,
BiCopy,
BiData,
BiHide,
BiLockAlt,
BiPencil,
} from 'react-icons/bi';
import Container from '@/components/Container';
import Checklist from '@/components/Checklist';
import { toastSuccess } from '@/toast';
import { ConfigChecklistStatus } from './config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
import { HiSparkles } from 'react-icons/hi';
import LoaderButton from '@/components/primitives/LoaderButton';
import { testConnectionsAction } from '@/admin/actions';
import ErrorNote from '@/components/ErrorNote';
import Spinner from '@/components/Spinner';
import WarningNote from '@/components/WarningNote';
import { RiSpeedMiniLine } from 'react-icons/ri';
import Link from 'next/link';
import SecretGenerator from './SecretGenerator';
import CopyButton from '@/components/CopyButton';
export default function SiteChecklistClient({
// Config checklist
// Storage
hasDatabase,
isPostgresSslEnabled,
hasVercelPostgres,
@ -39,38 +39,51 @@ export default function SiteChecklistClient({
hasAwsS3Storage,
hasMultipleStorageProviders,
currentStorage,
// Auth
hasAuthSecret,
hasAdminUser,
// Content
hasDomain,
hasTitle,
hasDescription,
hasAbout,
hasDefaultTheme,
showRepoLink,
showSocial,
showFilmSimulations,
showExifInfo,
defaultTheme,
isProModeEnabled,
isGridHomepageEnabled,
isStaticallyOptimized,
arePagesStaticallyOptimized,
areOGImagesStaticallyOptimized,
arePhotosMatted,
isBlurEnabled,
isGeoPrivacyEnabled,
isPriorityOrderEnabled,
// AI
isAiTextGenerationEnabled,
aiTextAutoGeneratedFields,
hasAiTextAutoGeneratedFields,
isPublicApiEnabled,
arePublicDownloadsEnabled,
isOgTextBottomAligned,
isImageActionsEnabled,
// Performance
isStaticallyOptimized,
arePhotosStaticallyOptimized,
arePhotoOGImagesStaticallyOptimized,
arePhotoCategoriesStaticallyOptimized,
areOriginalUploadsPreserved,
isBlurEnabled,
// Display
showExifInfo,
showTakenAtTimeHidden,
showSocial,
showFilmSimulations,
showRepoLink,
// Settings
isGridHomepageEnabled,
hasDefaultTheme,
defaultTheme,
arePhotosMatted,
isGeoPrivacyEnabled,
gridAspectRatio,
hasGridAspectRatio,
gridDensity,
hasGridDensityPreference,
arePublicDownloadsEnabled,
isPublicApiEnabled,
isPriorityOrderEnabled,
isOgTextBottomAligned,
isImageActionsEnabled,
// Misc
baseUrl,
commitSha,
commitMessage,
commitUrl,
// Connection status
databaseError,
storageError,
@ -79,15 +92,10 @@ export default function SiteChecklistClient({
// Component props
simplifiedView,
isTestingConnections,
secret,
baseUrl,
commitSha,
commitMessage,
}: ConfigChecklistStatus &
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean
isTestingConnections?: boolean
secret?: string
}) {
const renderLink = (href: string, text: string, external = true) =>
<>
@ -110,23 +118,6 @@ export default function SiteChecklistClient({
</>}
</>;
const renderCopyButton = (label: string, text?: string, subtle?: boolean) =>
<LoaderButton
icon={<BiCopy size={15} />}
className={clsx(
'translate-y-[2px]',
subtle && 'text-gray-300 dark:text-gray-700',
)}
onClick={text
? () => {
navigator.clipboard.writeText(text);
toastSuccess(`${label} copied to clipboard`);
}
: undefined}
styleAs="link"
disabled={!text}
/>;
const renderEnvVar = (
variable: string,
minimal?: boolean,
@ -147,7 +138,7 @@ export default function SiteChecklistClient({
)}>
`{variable}`
</span>
{!minimal && renderCopyButton(variable, variable, true)}
{!minimal && <CopyButton label={variable} text={variable} subtle />}
</span>
</div>;
@ -169,6 +160,16 @@ export default function SiteChecklistClient({
{label}
</span>
</div>;
const renderSubStatusWithEnvVar = (
type: ComponentProps<typeof StatusIcon>['type'],
variable: string,
) =>
renderSubStatus(
type,
renderEnvVars([variable]),
'translate-y-[5px]',
);
const renderError = ({
connection,
@ -301,18 +302,7 @@ export default function SiteChecklistClient({
Store auth secret in environment variable:
{!hasAuthSecret &&
<div className="overflow-x-auto">
<Container className="my-1.5 inline-flex" padding="tight">
<div className={clsx(
'flex flex-nowrap items-center gap-2 leading-none -mx-1',
)}>
{secret ? <span>{secret}</span> : <Spinner />}
<div
className="flex items-center gap-0.5 translate-y-[-2px]"
>
{renderCopyButton('Secret', secret)}
</div>
</div>
</Container>
<SecretGenerator />
</div>}
{renderEnvVars(['AUTH_SECRET'])}
</ChecklistRow>
@ -426,6 +416,102 @@ export default function SiteChecklistClient({
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Performance"
icon={<RiSpeedMiniLine size={18} />}
optional
>
<ChecklistRow
title="Static optimization"
status={isStaticallyOptimized}
optional
>
Set environment variable to {'"1"'} to make site more responsive
by enabling static optimization
(i.e., rendering pages and images at build time):
{renderSubStatusWithEnvVar(
arePhotosStaticallyOptimized ? 'checked' : 'optional',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
)}
{renderSubStatusWithEnvVar(
arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES',
)}
{renderSubStatusWithEnvVar(
arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES',
)}
</ChecklistRow>
<ChecklistRow
title="Preserve original uploads"
status={areOriginalUploadsPreserved}
optional
>
Set environment variable to {'"1"'} to prevent
image uploads being compressed before storing:
{renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])}
</ChecklistRow>
<ChecklistRow
title="Image blur"
status={isBlurEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed:
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Display"
icon={<BiHide size={18} />}
optional
>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title="Show taken at time"
status={showTakenAtTimeHidden}
optional
>
Set environment variable to {'"1"'} to hide
taken at time from photo meta:
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])}
</ChecklistRow>
<ChecklistRow
title="Show social"
status={showSocial}
optional
>
Set environment variable to {'"1"'} to hide
{' '}
X button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm simulations"
status={showFilmSimulations}
optional
>
Set environment variable to {'"1"'} to prevent
simulations showing up in /grid sidebar and
CMD-K results:
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Settings"
icon={<BiCog size={16} />}
@ -452,34 +538,6 @@ export default function SiteChecklistClient({
(defaults to {'\'system\''}):
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
</ChecklistRow>
<ChecklistRow
title="Pro mode"
status={isProModeEnabled}
optional
>
Set environment variable to {'"1"'} to enable
higher quality image storage:
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
</ChecklistRow>
<ChecklistRow
title="Static optimization"
status={isStaticallyOptimized}
optional
experimental
>
Set environment variable to {'"1"'} to enable static optimization,
i.e., rendering pages and images at build time:
{renderSubStatus(
arePagesStaticallyOptimized ? 'checked' : 'optional',
renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES']),
'translate-y-[3.5px]',
)}
{renderSubStatus(
areOGImagesStaticallyOptimized ? 'checked' : 'optional',
renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES']),
'translate-y-[3.5px]',
)}
</ChecklistRow>
<ChecklistRow
title="Photo matting"
status={arePhotosMatted}
@ -490,15 +548,6 @@ export default function SiteChecklistClient({
of each photo, and enable a surrounding border:
{renderEnvVars(['NEXT_PUBLIC_MATTE_PHOTOS'])}
</ChecklistRow>
<ChecklistRow
title="Image blur"
status={isBlurEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed:
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
<ChecklistRow
title="Geo privacy"
status={isGeoPrivacyEnabled}
@ -509,12 +558,24 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
Set environment variable to any number to enforce aspect ratio
{' '}
(default is {'"1"'}, i.e., square)set to {'"0"'} to disable:
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
</ChecklistRow>
<ChecklistRow
title={`Grid density: ${gridDensity ? 'low' : 'high'}`}
status={hasGridDensityPreference}
optional
>
Set environment variable to {'"1"'} to ensure large thumbnails
on photo grid views (if not configured, density is based on
aspect ratio configuration):
{renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])}
</ChecklistRow>
<ChecklistRow
title="Public downloads"
@ -543,54 +604,6 @@ export default function SiteChecklistClient({
priority order photo field affecting photo order:
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
</ChecklistRow>
<ChecklistRow
title="Show social"
status={showSocial}
optional
>
Set environment variable to {'"1"'} to hide
{' '}
X button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm simulations"
status={showFilmSimulations}
optional
>
Set environment variable to {'"1"'} to prevent
simulations showing up in /grid sidebar and
CMD-K results:
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
</ChecklistRow>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
optional
>
Set environment variable to any number to enforce aspect ratio
{' '}
(default is {'"1"'}, i.e., square)set to {'"0"'} to disable:
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
</ChecklistRow>
<ChecklistRow
title={`Grid density: ${gridDensity ? 'low' : 'high'}`}
status={hasGridDensityPreference}
optional
>
Set environment variable to {'"1"'} to ensure large thumbnails
on photo grid views (if not configured, density is based on
aspect ratio configuration):
{renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])}
</ChecklistRow>
<ChecklistRow
title="Legacy OG text alignment"
status={isOgTextBottomAligned}
@ -630,7 +643,15 @@ export default function SiteChecklistClient({
<span className="font-bold">Commit</span>
&nbsp;&nbsp;
{commitSha
? <span title={commitMessage}>{commitSha}</span>
? commitUrl
? <Link
title={commitMessage}
href={commitUrl}
target="_blank"
>
{commitSha}
</Link>
: <span title={commitMessage}>{commitSha}</span>
: 'Not Found'}
</div>
</div>}

View File

@ -1,4 +1,3 @@
import { generateAuthSecret } from '@/auth';
import SiteChecklistClient from './SiteChecklistClient';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
import { testConnectionsAction } from '@/admin/actions';
@ -8,14 +7,12 @@ export default async function SiteChecklistServer({
}: {
simplifiedView?: boolean
}) {
const secret = await generateAuthSecret().catch(() => 'TRY AGAIN');
const connectionErrors = await testConnectionsAction().catch(() => ({}));
return (
<SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS,
...connectionErrors,
simplifiedView,
secret,
}} />
);
}

View File

@ -14,10 +14,23 @@ export const SITE_TITLE =
'Photo Blog';
// SOURCE
export const VERCEL_COMMIT_MESSAGE =
const VERCEL_GIT_PROVIDER =
process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER;
const VERCEL_GIT_REPO_OWNER =
process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER;
const VERCEL_GIT_REPO_SLUG =
process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG;
const VERCEL_GIT_COMMIT_MESSAGE =
process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_MESSAGE;
export const VERCEL_COMMIT_SHA =
const VERCEL_GIT_COMMIT_SHA =
process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA;
const VERCEL_GIT_COMMIT_SHA_SHORT = VERCEL_GIT_COMMIT_SHA
? VERCEL_GIT_COMMIT_SHA.slice(0, 7)
: undefined;
const VERCEL_GIT_COMMIT_URL = VERCEL_GIT_PROVIDER === 'github'
// eslint-disable-next-line max-len
? `https://github.com/${VERCEL_GIT_REPO_OWNER}/${VERCEL_GIT_REPO_SLUG}/commit/${VERCEL_GIT_COMMIT_SHA}`
: undefined;
const VERCEL_ENV = process.env.NEXT_PUBLIC_VERCEL_ENV;
const VERCEL_PRODUCTION_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL;
@ -122,63 +135,85 @@ export const CURRENT_STORAGE: StorageType =
: 'vercel-blob'
);
// AI
export const AI_TEXT_GENERATION_ENABLED =
Boolean(process.env.OPENAI_SECRET_KEY);
export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText(
process.env.AI_TEXT_AUTO_GENERATED_FIELDS);
// PERFORMANCE
export const STATICALLY_OPTIMIZED_PHOTOS =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS === '1' ||
// Legacy environment variable name
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES === '1';
export const STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES === '1' ||
// Legacy environment variable name
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES === '1';
export const STATICALLY_OPTIMIZED_PHOTO_CATEGORIES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES === '1';
export const PRESERVE_ORIGINAL_UPLOADS =
process.env.NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS === '1' ||
// Legacy environment variable name
process.env.NEXT_PUBLIC_PRO_MODE === '1';
export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
// DISPLAY
export const SHOW_EXIF_DATA =
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
export const SHOW_TAKEN_AT_TIME =
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
export const SHOW_SOCIAL =
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
export const SHOW_FILM_SIMULATIONS =
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
export const SHOW_REPO_LINK =
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
// SETTINGS
export const GRID_HOMEPAGE_ENABLED =
process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1';
export const DEFAULT_THEME =
process.env.NEXT_PUBLIC_DEFAULT_THEME === 'dark'
? 'dark'
: process.env.NEXT_PUBLIC_DEFAULT_THEME === 'light'
? 'light'
: 'system';
export const PRO_MODE_ENABLED =
process.env.NEXT_PUBLIC_PRO_MODE === '1';
export const GRID_HOMEPAGE_ENABLED =
process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1';
export const STATICALLY_OPTIMIZED_PAGES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES === '1';
export const STATICALLY_OPTIMIZED_OG_IMAGES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES === '1';
export const MATTE_PHOTOS =
process.env.NEXT_PUBLIC_MATTE_PHOTOS === '1';
export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
export const GEO_PRIVACY_ENABLED =
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
export const AI_TEXT_GENERATION_ENABLED =
Boolean(process.env.OPENAI_SECRET_KEY);
export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText(
process.env.AI_TEXT_AUTO_GENERATED_FIELDS);
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 =
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
export const SHOW_FILM_SIMULATIONS =
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
export const SHOW_EXIF_DATA =
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
export const GRID_ASPECT_RATIO =
process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO
? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO)
: 1;
export const OG_TEXT_BOTTOM_ALIGNMENT =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
export const IMAGE_ACTIONS_ENABLED =
process.env.NEXT_PUBLIC_IMAGE_ACTIONS === '1';
export const ADMIN_DEBUG_TOOLS_ENABLED = process.env.ADMIN_DEBUG_TOOLS === '1';
export const PREFERS_LOW_DENSITY_GRID =
process.env.NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS === '1';
export const HIGH_DENSITY_GRID =
GRID_ASPECT_RATIO <= 1 &&
!PREFERS_LOW_DENSITY_GRID;
export const ALLOW_PUBLIC_DOWNLOADS =
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
export const PUBLIC_API_ENABLED =
process.env.NEXT_PUBLIC_PUBLIC_API === '1';
export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const OG_TEXT_BOTTOM_ALIGNMENT =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
export const IMAGE_ACTIONS_ENABLED =
process.env.NEXT_PUBLIC_IMAGE_ACTIONS === '1';
// INTERNAL
export const ADMIN_DEBUG_TOOLS_ENABLED = process.env.ADMIN_DEBUG_TOOLS === '1';
export const CONFIG_CHECKLIST_STATUS = {
// STORAGE
hasDatabase: HAS_DATABASE,
isPostgresSslEnabled: POSTGRES_SSL_ENABLED,
hasVercelPostgres: (
@ -196,32 +231,18 @@ export const CONFIG_CHECKLIST_STATUS = {
),
hasMultipleStorageProviders: HAS_MULTIPLE_STORAGE_PROVIDERS,
currentStorage: CURRENT_STORAGE,
// AUTH
hasAuthSecret: Boolean(process.env.AUTH_SECRET),
hasAdminUser: (
Boolean(process.env.ADMIN_EMAIL) &&
Boolean(process.env.ADMIN_PASSWORD)
),
// CONTENT
hasDomain: Boolean(process.env.NEXT_PUBLIC_SITE_DOMAIN),
hasTitle: Boolean(process.env.NEXT_PUBLIC_SITE_TITLE),
hasDescription: HAS_DEFINED_SITE_DESCRIPTION,
hasAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT),
hasDefaultTheme: Boolean(process.env.NEXT_PUBLIC_DEFAULT_THEME),
showRepoLink: SHOW_REPO_LINK,
showSocial: SHOW_SOCIAL,
showFilmSimulations: SHOW_FILM_SIMULATIONS,
showExifInfo: SHOW_EXIF_DATA,
defaultTheme: DEFAULT_THEME,
isProModeEnabled: PRO_MODE_ENABLED,
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
isStaticallyOptimized: (
STATICALLY_OPTIMIZED_PAGES ||
STATICALLY_OPTIMIZED_OG_IMAGES
),
arePagesStaticallyOptimized: STATICALLY_OPTIMIZED_PAGES,
areOGImagesStaticallyOptimized: STATICALLY_OPTIMIZED_OG_IMAGES,
arePhotosMatted: MATTE_PHOTOS,
isBlurEnabled: BLUR_ENABLED,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
// AI
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
@ -230,19 +251,44 @@ export const CONFIG_CHECKLIST_STATUS = {
: ['all'],
hasAiTextAutoGeneratedFields:
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,
isImageActionsEnabled: IMAGE_ACTIONS_ENABLED,
// PERFORMANCE
isStaticallyOptimized: (
STATICALLY_OPTIMIZED_PHOTOS ||
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES ||
STATICALLY_OPTIMIZED_PHOTO_CATEGORIES
),
arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS,
arePhotoOGImagesStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES,
arePhotoCategoriesStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTO_CATEGORIES,
areOriginalUploadsPreserved: PRESERVE_ORIGINAL_UPLOADS,
isBlurEnabled: BLUR_ENABLED,
// DISPLAY
showExifInfo: SHOW_EXIF_DATA,
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,
showFilmSimulations: SHOW_FILM_SIMULATIONS,
showRepoLink: SHOW_REPO_LINK,
// SETTINGS
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
hasDefaultTheme: Boolean(process.env.NEXT_PUBLIC_DEFAULT_THEME),
defaultTheme: DEFAULT_THEME,
arePhotosMatted: MATTE_PHOTOS,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
gridAspectRatio: GRID_ASPECT_RATIO,
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
gridDensity: HIGH_DENSITY_GRID,
hasGridDensityPreference:
Boolean(process.env.NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS),
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
isPublicApiEnabled: PUBLIC_API_ENABLED,
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
isImageActionsEnabled: IMAGE_ACTIONS_ENABLED,
// MISC
baseUrl: BASE_URL,
commitSha: VERCEL_COMMIT_SHA ? VERCEL_COMMIT_SHA.slice(0, 7) : undefined,
commitMessage: VERCEL_COMMIT_MESSAGE,
commitSha: VERCEL_GIT_COMMIT_SHA_SHORT,
commitMessage: VERCEL_GIT_COMMIT_MESSAGE,
commitUrl: VERCEL_GIT_COMMIT_URL,
};
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;

View File

@ -4,6 +4,8 @@
[data-sonner-toaster] {
position: fixed;
--mobile-offset: 12px !important;
--mobile-offset-left: var(--mobile-offset) !important;
--mobile-offset-right: var(--mobile-offset) !important;
right: var(--mobile-offset);
left: var(--mobile-offset);
width: 100% !important;

View File

@ -2,56 +2,78 @@ import { parseISO, parse, format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { Timezone } from './timezone';
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000';
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000';
const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma';
const DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000';
const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma';
const DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000';
const DATE_STRING_FORMAT_LONG = 'dd MMM yyyy h:mma';
const DATE_STRING_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000';
const DATE_STRING_FORMAT_LONG = 'dd MMM yyyy h:mma';
const DATE_STRING_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000';
const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss';
const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss';
export const VALIDATION_EXAMPLE_POSTGRES = '2025-01-03T21:00:44.000Z';
export const VALIDATION_EXAMPLE_POSTGRES_NAIVE = '2025-01-03 16:00:44';
type AmbiguousTimestamp = number | string;
type Length = 'tiny' | 'short' | 'medium' | 'long';
export const formatDate = (
export const formatDate = ({
date,
length = 'long',
timezone,
hideTime,
showPlaceholder,
}: {
date: Date,
length: Length = 'long',
length?: Length,
timezone?: Timezone,
hideTime?: boolean,
showPlaceholder?: boolean,
) => {
switch (length) {
case 'tiny': return showPlaceholder
? DATE_STRING_FORMAT_TINY_PLACEHOLDER
: timezone
? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_TINY)
: format(date, DATE_STRING_FORMAT_TINY);
case 'short': return showPlaceholder
? DATE_STRING_FORMAT_SHORT_PLACEHOLDER
: timezone
? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_SHORT)
: format(date, DATE_STRING_FORMAT_SHORT);
case 'medium': return showPlaceholder
? DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER
: timezone
? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_MEDIUM)
: format(date, DATE_STRING_FORMAT_MEDIUM);
default: return showPlaceholder
}) => {
let formatString = !hideTime
? DATE_STRING_FORMAT_LONG
: DATE_STRING_FORMAT_SHORT;
let placeholderString = !hideTime
? DATE_STRING_FORMAT_LONG_PLACEHOLDER
: timezone
? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_LONG)
: format(date, DATE_STRING_FORMAT_LONG);
: DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
switch (length) {
case 'tiny':
formatString = DATE_STRING_FORMAT_TINY;
placeholderString = DATE_STRING_FORMAT_TINY_PLACEHOLDER;
break;
case 'short':
formatString = DATE_STRING_FORMAT_SHORT;
placeholderString = DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
break;
case 'medium':
formatString = !hideTime
? DATE_STRING_FORMAT_MEDIUM
: DATE_STRING_FORMAT_SHORT;
placeholderString = !hideTime
? DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER
: DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
break;
}
return showPlaceholder
? placeholderString
: timezone
? formatInTimeZone(date, timezone, formatString)
: format(date, formatString);
};
export const formatDateFromPostgresString = (date: string, length?: Length) =>
formatDate(parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()), length);
formatDate({
date: parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()),
length,
});
export const formatDateForPostgres = (date: Date) =>
date.toISOString().replace(
@ -103,3 +125,23 @@ export const generateLocalPostgresString = () =>
export const generateLocalNaivePostgresString = () =>
format(new Date(), DATE_STRING_FORMAT_POSTGRES);
// Form validation to prevent Postgres runtime errors
// POSTGRES: 2025-01-03T21:00:44.000Z
export const validatePostgresDateString = (date = ''): boolean =>
/^(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}:\d{2}(.[\d]+)*Z$/.test(date);
export const validationMessagePostgresDateString = (date = '') =>
validatePostgresDateString(date)
? undefined
: `Invalid format (${VALIDATION_EXAMPLE_POSTGRES})`;
// NAIVE: 2025-01-03 16:00:44
export const validateNaivePostgresDateString = (date = ''): boolean =>
/^(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}$/.test(date);
export const validationMessageNaivePostgresDateString = (date = '') =>
validateNaivePostgresDateString(date)
? undefined
: `Invalid format (${VALIDATION_EXAMPLE_POSTGRES_NAIVE})`;

View File

@ -5,6 +5,13 @@ export const shortenUrl = (url?: string) => url
.replace(/\/$/, '')
: undefined;
// Remove protocol, and trailing slash from url
export const removeUrlProtocol = (url?: string) => url
? url
.replace(/^(?:https?:\/\/)?/i, '')
.replace(/\/$/, '')
: undefined;
// Add protocol to url and remove trailing slash
export const makeUrlAbsolute = (url?: string) => url !== undefined
? (!url.startsWith('http') ? `https://${url}` : url)

View File

@ -0,0 +1,14 @@
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function useOnPathChange(onPathChange: () => void) {
const path = usePathname();
const initialPath = useRef(path);
useEffect(() => {
if (initialPath.current !== path) {
onPathChange();
}
}, [path, onPathChange]);
}

View File

@ -28,12 +28,18 @@ module.exports = {
animation: {
'rotate-pulse':
'rotate-pulse 0.75s linear infinite normal both running',
'fade-in':
'fade-in 0.5s linear',
'hover-drift':
'hover-drift 8s linear infinite',
'hover-wobble':
'hover-wobble 6s linear infinite normal both running',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'rotate-pulse': {
'0%': { transform: 'rotate(0deg) scale(1)' },
'50%': { transform: 'rotate(180deg) scale(0.8)' },