Merge branch 'main' of https://github.com/sambecker/exif-photo-blog
This commit is contained in:
commit
cd7b49042b
31
README.md
31
README.md
@ -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 don’t 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`.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
34
package.json
34
package.json
@ -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
1296
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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" />}>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 }>
|
||||
}
|
||||
|
||||
@ -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 }>
|
||||
}
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 }>
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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();
|
||||
|
||||
32
src/components/CopyButton.tsx
Normal file
32
src/components/CopyButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
31
src/components/LinkWithLoader.tsx
Normal file
31
src/components/LinkWithLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/LinkWithStatus.tsx
Normal file
122
src/components/LinkWithStatus.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
};
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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">
|
||||
|
||||
51
src/site/SecretGenerator.tsx
Normal file
51
src/site/SecretGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
{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>}
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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})`;
|
||||
|
||||
@ -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)
|
||||
|
||||
14
src/utility/useOnPathChange.ts
Normal file
14
src/utility/useOnPathChange.ts
Normal 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]);
|
||||
}
|
||||
@ -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)' },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user