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:
|
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_TITLE` (seen in browser tab)
|
||||||
- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, beneath title)
|
- `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>`)
|
- `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_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_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_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_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_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_PUBLIC_API = 1` enables public API available at `/api`
|
||||||
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
|
- `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_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_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)
|
- `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:
|
- Store public configuration:
|
||||||
- `NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET`: bucket name
|
- `NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET`: bucket name
|
||||||
- `NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID`: account id (found on R2 overview page)
|
- `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
|
2. Setup private credentials
|
||||||
- Create API token by selecting "Manage R2 API Tokens," and clicking "Create API Token"
|
- 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
|
- 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.
|
> 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?
|
#### 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?
|
#### 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`.
|
> 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 {
|
import {
|
||||||
convertTimestampToNaivePostgresString,
|
convertTimestampToNaivePostgresString,
|
||||||
convertTimestampWithOffsetToPostgresString,
|
convertTimestampWithOffsetToPostgresString,
|
||||||
|
validatePostgresDateString,
|
||||||
|
validateNaivePostgresDateString,
|
||||||
} from '../src/utility/date';
|
} from '../src/utility/date';
|
||||||
|
|
||||||
describe('Date utility', () => {
|
describe('Date utility', () => {
|
||||||
@ -29,19 +32,34 @@ describe('Date utility', () => {
|
|||||||
expect(convertTimestampToNaivePostgresString(timestamp))
|
expect(convertTimestampToNaivePostgresString(timestamp))
|
||||||
.toBe('2023-12-02 16:38:36');
|
.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', () => {
|
describe('validates date strings', () => {
|
||||||
const timestamp = '2024/01a/01 Z';
|
it('Correct', () => {
|
||||||
expect(convertTimestampWithOffsetToPostgresString(timestamp))
|
expect(validatePostgresDateString('2025-01-03T21:00:44.000Z')).toBe(true);
|
||||||
.toBe(convertTimestampWithOffsetToPostgresString(
|
expect(validateNaivePostgresDateString('2025-01-03 16:00:44')).toBe(true);
|
||||||
new Date().toISOString(),
|
});
|
||||||
));
|
it('Incorrect', () => {
|
||||||
});
|
expect(validatePostgresDateString('2024-01-01')).toBe(false);
|
||||||
it('Empty string', () => {
|
expect(validatePostgresDateString('2025-01-03 16:00:44')).toBe(false);
|
||||||
const timestamp = ' ';
|
expect(validateNaivePostgresDateString('2024-01-01')).toBe(false);
|
||||||
expect(convertTimestampWithOffsetToPostgresString(timestamp))
|
expect(validatePostgresDateString('2025-01-03T21:00:44.000')).toBe(false);
|
||||||
.toBe(convertTimestampWithOffsetToPostgresString(
|
expect(validateNaivePostgresDateString('2025-01-03T16:00:44')).toBe(false);
|
||||||
new Date().toISOString(),
|
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 { makeUrlAbsolute, removeUrlProtocol, shortenUrl } from '@/utility/url';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
const URL_LONG_1 = 'https://www.example.com/';
|
const URL_LONG_1 = 'https://www.example.com/';
|
||||||
const URL_LONG_2 = '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_1 = 'example.com';
|
||||||
const URL_SHORT_2 = 'example.com/';
|
const URL_SHORT_2 = 'example.com/';
|
||||||
const URL_SHORT_3 = 'example.com/final-path';
|
const URL_SHORT_3 = 'example.com/final-path';
|
||||||
|
const URL_SHORT_4 = 'www.example.com';
|
||||||
|
|
||||||
describe('String', () => {
|
describe('URL', () => {
|
||||||
it('url can be shortened', () => {
|
it('can be shortened', () => {
|
||||||
expect(shortenUrl(URL_LONG_1)).toBe(URL_SHORT_1);
|
expect(shortenUrl(URL_LONG_1)).toBe(URL_SHORT_1);
|
||||||
expect(shortenUrl(URL_LONG_2)).toBe(URL_SHORT_1);
|
expect(shortenUrl(URL_LONG_2)).toBe(URL_SHORT_1);
|
||||||
expect(shortenUrl(URL_LONG_3)).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_6)).toBe(URL_SHORT_3);
|
||||||
expect(shortenUrl(URL_LONG_7)).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_1)).toBe(URL_LONG_5);
|
||||||
expect(makeUrlAbsolute(URL_SHORT_2)).toBe(URL_LONG_5);
|
expect(makeUrlAbsolute(URL_SHORT_2)).toBe(URL_LONG_5);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const eslintConfig = [
|
|||||||
rules: {
|
rules: {
|
||||||
'@next/next/no-img-element': 'off',
|
'@next/next/no-img-element': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
'no-unused-expressions': ['warn'],
|
'no-unused-expressions': ['warn'],
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn', {
|
'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(
|
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
||||||
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
||||||
)?.[1].toLowerCase();
|
)?.[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`
|
? `${process.env.NEXT_PUBLIC_AWS_S3_BUCKET}.s3.${process.env.NEXT_PUBLIC_AWS_S3_REGION}.amazonaws.com`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const createRemotePattern = (hostname) => hostname
|
const generateRemotePattern = (hostname: string) =>
|
||||||
? {
|
({
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname,
|
hostname: removeUrlProtocol(hostname)!,
|
||||||
port: '',
|
port: '',
|
||||||
pathname: '/**',
|
pathname: '/**',
|
||||||
}
|
} as const);
|
||||||
: [];
|
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
const remotePatterns: RemotePattern[] = [];
|
||||||
const nextConfig = {
|
|
||||||
|
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: {
|
images: {
|
||||||
imageSizes: [200],
|
imageSizes: [200],
|
||||||
remotePatterns: []
|
remotePatterns,
|
||||||
.concat(createRemotePattern(HOSTNAME_VERCEL_BLOB))
|
|
||||||
.concat(createRemotePattern(HOSTNAME_CLOUDFLARE_R2))
|
|
||||||
.concat(createRemotePattern(HOSTNAME_AWS_S3)),
|
|
||||||
minimumCacheTTL: 31536000,
|
minimumCacheTTL: 31536000,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
34
package.json
34
package.json
@ -9,26 +9,26 @@
|
|||||||
"analyze": "ANALYZE=true next build"
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^1.0.18",
|
"@ai-sdk/openai": "^1.1.1",
|
||||||
"@aws-sdk/client-s3": "3.726.1",
|
"@aws-sdk/client-s3": "3.733.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.726.1",
|
"@aws-sdk/s3-request-presigner": "3.733.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.1",
|
"@radix-ui/react-visually-hidden": "^1.1.1",
|
||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@vercel/analytics": "^1.4.1",
|
"@vercel/analytics": "^1.4.1",
|
||||||
"@vercel/blob": "^0.27.0",
|
"@vercel/blob": "^0.27.1",
|
||||||
"@vercel/kv": "^3.0.0",
|
"@vercel/kv": "^3.0.0",
|
||||||
"@vercel/speed-insights": "^1.1.0",
|
"@vercel/speed-insights": "^1.1.0",
|
||||||
"ai": "^4.0.33",
|
"ai": "^4.1.1",
|
||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"framer-motion": "^11.17.0",
|
"framer-motion": "^12.0.1",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"next": "15.1.4",
|
"next": "15.1.6",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
@ -37,31 +37,31 @@
|
|||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"sanitize-html": "^2.14.0",
|
"sanitize-html": "^2.14.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.2",
|
||||||
"swr": "^2.3.0",
|
"swr": "^2.3.0",
|
||||||
"ts-exif-parser": "^0.2.2",
|
"ts-exif-parser": "^0.2.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"viewerjs": "^1.11.7"
|
"viewerjs": "^1.11.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "15.1.4",
|
"@next/bundle-analyzer": "15.1.6",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@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/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.7",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "19.0.4",
|
"@types/react": "19.0.7",
|
||||||
"@types/react-dom": "19.0.2",
|
"@types/react-dom": "19.0.3",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "9.18.0",
|
"eslint": "9.18.0",
|
||||||
"eslint-config-next": "15.1.4",
|
"eslint-config-next": "15.1.6",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "8.4.49",
|
"postcss": "8.5.1",
|
||||||
"tailwindcss": "3.4.17",
|
"tailwindcss": "3.4.17",
|
||||||
"typescript": "5.7.3"
|
"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';
|
'use client';
|
||||||
|
|
||||||
|
import LinkWithLoader from '@/components/LinkWithLoader';
|
||||||
|
import LinkWithStatus from '@/components/LinkWithStatus';
|
||||||
import Note from '@/components/Note';
|
import Note from '@/components/Note';
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
import SiteGrid from '@/components/SiteGrid';
|
||||||
|
import Spinner from '@/components/Spinner';
|
||||||
import {
|
import {
|
||||||
PATH_ADMIN_CONFIGURATION,
|
PATH_ADMIN_CONFIGURATION,
|
||||||
checkPathPrefix,
|
checkPathPrefix,
|
||||||
@ -11,7 +14,6 @@ import {
|
|||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { differenceInMinutes } from 'date-fns';
|
import { differenceInMinutes } from 'date-fns';
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { BiCog } from 'react-icons/bi';
|
import { BiCog } from 'react-icons/bi';
|
||||||
@ -60,40 +62,43 @@ export default function AdminNavClient({
|
|||||||
contentMain={
|
contentMain={
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex gap-2 md:gap-4',
|
'flex gap-2 pb-3',
|
||||||
'border-b border-gray-200 dark:border-gray-800 pb-3',
|
'border-b border-gray-200 dark:border-gray-800',
|
||||||
)}>
|
)}>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex gap-2 md:gap-4',
|
'flex gap-0.5 md:gap-1.5 -mx-1',
|
||||||
'flex-grow overflow-x-auto',
|
'flex-grow overflow-x-auto',
|
||||||
)}>
|
)}>
|
||||||
{items.map(({ label, href, count }) =>
|
{items.map(({ label, href, count }) =>
|
||||||
<Link
|
<LinkWithStatus
|
||||||
key={label}
|
key={label}
|
||||||
href={href}
|
href={href}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex gap-0.5',
|
'flex gap-0.5',
|
||||||
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
|
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
|
||||||
|
'px-1 py-0.5 rounded-md',
|
||||||
)}
|
)}
|
||||||
|
loadingClassName="bg-dim"
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{count > 0 &&
|
{count > 0 &&
|
||||||
<span>({count})</span>}
|
<span>({count})</span>}
|
||||||
</Link>)}
|
</LinkWithStatus>)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<LinkWithLoader
|
||||||
href={PATH_ADMIN_CONFIGURATION}
|
href={PATH_ADMIN_CONFIGURATION}
|
||||||
className={isPathAdminConfiguration(pathname)
|
className={isPathAdminConfiguration(pathname)
|
||||||
? 'font-bold'
|
? 'font-bold'
|
||||||
: 'text-dim'}
|
: 'text-dim'}
|
||||||
|
loader={<Spinner />}
|
||||||
>
|
>
|
||||||
<BiCog
|
<BiCog
|
||||||
size={18}
|
size={18}
|
||||||
className="inline-block"
|
className="inline-flex translate-y-0.5"
|
||||||
aria-label="App Configuration"
|
aria-label="App Configuration"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</LinkWithLoader>
|
||||||
</div>
|
</div>
|
||||||
{shouldShowBanner &&
|
{shouldShowBanner &&
|
||||||
<Note icon={<FaRegClock className="flex-shrink-0" />}>
|
<Note icon={<FaRegClock className="flex-shrink-0" />}>
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
import PhotoUpload from '@/photo/PhotoUpload';
|
import PhotoUpload from '@/photo/PhotoUpload';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
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 AdminPhotosTable from '@/admin/AdminPhotosTable';
|
||||||
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
|
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
|
||||||
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
|
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
|
||||||
@ -43,7 +46,7 @@ export default function AdminPhotosClient({
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="grow min-w-0">
|
<div className="grow min-w-0">
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
shouldResize={!PRO_MODE_ENABLED}
|
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
setIsUploading={setIsUploading}
|
setIsUploading={setIsUploading}
|
||||||
onLastUpload={onLastPhotoUpload}
|
onLastUpload={onLastPhotoUpload}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
AI_TEXT_GENERATION_ENABLED,
|
AI_TEXT_GENERATION_ENABLED,
|
||||||
BLUR_ENABLED,
|
BLUR_ENABLED,
|
||||||
} from '@/site/config';
|
} from '@/site/config';
|
||||||
|
import ErrorNote from '@/components/ErrorNote';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@ -23,16 +24,19 @@ export default async function UploadPage({ params }: Params) {
|
|||||||
photoFormExif,
|
photoFormExif,
|
||||||
imageResizedBase64: imageThumbnailBase64,
|
imageResizedBase64: imageThumbnailBase64,
|
||||||
shouldStripGpsData,
|
shouldStripGpsData,
|
||||||
|
error,
|
||||||
} = await extractImageDataFromBlobPath(uploadPath, {
|
} = await extractImageDataFromBlobPath(uploadPath, {
|
||||||
includeInitialPhotoFields: true,
|
includeInitialPhotoFields: true,
|
||||||
generateBlurData: BLUR_ENABLED,
|
generateBlurData: BLUR_ENABLED,
|
||||||
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
|
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
const isDataMissing =
|
||||||
!photoFormExif ||
|
!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);
|
redirect(PATH_ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,14 +47,18 @@ export default async function UploadPage({ params }: Params) {
|
|||||||
const textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS;
|
const textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UploadPageClient {...{
|
!isDataMissing
|
||||||
blobId,
|
? <UploadPageClient {...{
|
||||||
photoFormExif,
|
blobId,
|
||||||
uniqueTags,
|
photoFormExif,
|
||||||
hasAiTextGeneration,
|
uniqueTags,
|
||||||
textFieldsToAutoGenerate,
|
hasAiTextGeneration,
|
||||||
imageThumbnailBase64,
|
textFieldsToAutoGenerate,
|
||||||
shouldStripGpsData,
|
imageThumbnailBase64,
|
||||||
}} />
|
shouldStripGpsData,
|
||||||
|
}} />
|
||||||
|
: <ErrorNote>
|
||||||
|
{error ?? 'Unknown error'}
|
||||||
|
</ErrorNote>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||||
|
import { getUniqueFilmSimulations } from '@/photo/db/query';
|
||||||
import { FilmSimulation, generateMetaForFilmSimulation } from '@/simulation';
|
import { FilmSimulation, generateMetaForFilmSimulation } from '@/simulation';
|
||||||
import FilmSimulationOverview from '@/simulation/FilmSimulationOverview';
|
import FilmSimulationOverview from '@/simulation/FilmSimulationOverview';
|
||||||
|
import { IS_PRODUCTION } from '@/site/config';
|
||||||
import { getPhotosFilmSimulationDataCached } from '@/simulation/data';
|
import { getPhotosFilmSimulationDataCached } from '@/simulation/data';
|
||||||
|
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
|
||||||
import { Metadata } from 'next/types';
|
import { Metadata } from 'next/types';
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
|
|
||||||
const getPhotosFilmSimulationDataCachedCached =
|
const getPhotosFilmSimulationDataCachedCached =
|
||||||
cache(getPhotosFilmSimulationDataCached);
|
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 {
|
interface FilmSimulationProps {
|
||||||
params: Promise<{ simulation: FilmSimulation }>
|
params: Promise<{ simulation: FilmSimulation }>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { generateMetaForFocalLength, getFocalLengthFromString } from '@/focal';
|
|||||||
import FocalLengthOverview from '@/focal/FocalLengthOverview';
|
import FocalLengthOverview from '@/focal/FocalLengthOverview';
|
||||||
import { getPhotosFocalLengthDataCached } from '@/focal/data';
|
import { getPhotosFocalLengthDataCached } from '@/focal/data';
|
||||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
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 { PATH_ROOT } from '@/site/paths';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
@ -13,6 +16,16 @@ const getPhotosFocalDataCachedCached = cache((focal: number) =>
|
|||||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
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 {
|
interface FocalLengthProps {
|
||||||
params: Promise<{ focal: string }>
|
params: Promise<{ focal: string }>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import PhotoImageResponse from '@/image-response/PhotoImageResponse';
|
|||||||
import { getIBMPlexMonoMedium } from '@/site/font';
|
import { getIBMPlexMonoMedium } from '@/site/font';
|
||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
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 { getPhotoIds } from '@/photo/db/query';
|
||||||
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
|
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
|
||||||
import { isNextImageReadyBasedOnPhotos } from '@/photo';
|
import { isNextImageReadyBasedOnPhotos } from '@/photo';
|
||||||
@ -12,7 +15,7 @@ import { isNextImageReadyBasedOnPhotos } from '@/photo';
|
|||||||
export let generateStaticParams:
|
export let generateStaticParams:
|
||||||
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
|
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
|
||||||
|
|
||||||
if (STATICALLY_OPTIMIZED_OG_IMAGES && IS_PRODUCTION) {
|
if (STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES && IS_PRODUCTION) {
|
||||||
generateStaticParams = async () => {
|
generateStaticParams = async () => {
|
||||||
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
|
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
|
||||||
return photos.map(photoId => ({ photoId }));
|
return photos.map(photoId => ({ photoId }));
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
} from '@/site/paths';
|
} from '@/site/paths';
|
||||||
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||||
import { getPhotosNearIdCached } from '@/photo/cache';
|
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 { getPhotoIds } from '@/photo/db/query';
|
||||||
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
|
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
@ -25,7 +25,7 @@ const getPhotosNearIdCachedCached = cache((photoId: string) =>
|
|||||||
export let generateStaticParams:
|
export let generateStaticParams:
|
||||||
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
|
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
|
||||||
|
|
||||||
if (STATICALLY_OPTIMIZED_PAGES && IS_PRODUCTION) {
|
if (STATICALLY_OPTIMIZED_PHOTOS && IS_PRODUCTION) {
|
||||||
generateStaticParams = async () => {
|
generateStaticParams = async () => {
|
||||||
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
|
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
|
||||||
return photos.map(photoId => ({ photoId }));
|
return photos.map(photoId => ({ photoId }));
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
|||||||
import { getPhotosCameraDataCached } from '@/camera/data';
|
import { getPhotosCameraDataCached } from '@/camera/data';
|
||||||
import CameraOverview from '@/camera/CameraOverview';
|
import CameraOverview from '@/camera/CameraOverview';
|
||||||
import { cache } from 'react';
|
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((
|
const getPhotosCameraDataCachedCached = cache((
|
||||||
make: string,
|
make: string,
|
||||||
@ -15,6 +18,16 @@ const getPhotosCameraDataCachedCached = cache((
|
|||||||
INFINITE_SCROLL_GRID_INITIAL,
|
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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: CameraProps): Promise<Metadata> {
|
}: CameraProps): Promise<Metadata> {
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
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 { PATH_ROOT } from '@/site/paths';
|
||||||
import { generateMetaForTag } from '@/tag';
|
import { generateMetaForTag } from '@/tag';
|
||||||
import TagOverview from '@/tag/TagOverview';
|
import TagOverview from '@/tag/TagOverview';
|
||||||
@ -10,6 +13,16 @@ import { cache } from 'react';
|
|||||||
const getPhotosTagDataCachedCached = cache((tag: string) =>
|
const getPhotosTagDataCachedCached = cache((tag: string) =>
|
||||||
getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL}));
|
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 {
|
interface TagProps {
|
||||||
params: Promise<{ tag: string }>
|
params: Promise<{ tag: string }>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
|||||||
import {
|
import {
|
||||||
useActionState,
|
useActionState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@ -28,8 +27,9 @@ export default function SignInForm() {
|
|||||||
const [response, action] = useActionState(signInAction, undefined);
|
const [response, action] = useActionState(signInAction, undefined);
|
||||||
|
|
||||||
const emailRef = useRef<HTMLInputElement>(null);
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
emailRef.current?.focus();
|
const timeout = setTimeout(() => emailRef.current?.focus(), 100);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
KEY_CREDENTIALS_SIGN_IN_ERROR,
|
KEY_CREDENTIALS_SIGN_IN_ERROR,
|
||||||
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
|
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
|
||||||
auth,
|
auth,
|
||||||
|
generateAuthSecret,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
} from '@/auth';
|
} from '@/auth';
|
||||||
@ -47,3 +48,5 @@ export const getAuthAction = async () => auth();
|
|||||||
|
|
||||||
export const logClientAuthUpdate = async (data: Session | null | undefined) =>
|
export const logClientAuthUpdate = async (data: Session | null | undefined) =>
|
||||||
console.log('Client auth update', data);
|
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 &&
|
{!hideLabel && label &&
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
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]',
|
type === 'checkbox' && 'order-2 pt-[3px]',
|
||||||
)}
|
)}
|
||||||
htmlFor={id}
|
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,
|
className,
|
||||||
titleLabel,
|
titleLabel,
|
||||||
timezone: timezoneFromProps,
|
timezone: timezoneFromProps,
|
||||||
|
hideTime,
|
||||||
}: {
|
}: {
|
||||||
date: Date
|
date: Date
|
||||||
className?: string
|
className?: string
|
||||||
titleLabel?: string
|
titleLabel?: string
|
||||||
timezone?: Timezone
|
timezone?: Timezone
|
||||||
|
hideTime?: boolean,
|
||||||
}) {
|
}) {
|
||||||
const [timezone, setTimezone] = useState(timezoneFromProps);
|
const [timezone, setTimezone] = useState(timezoneFromProps);
|
||||||
|
|
||||||
@ -24,23 +26,30 @@ export default function ResponsiveDate({
|
|||||||
}
|
}
|
||||||
}, [timezoneFromProps]);
|
}, [timezoneFromProps]);
|
||||||
|
|
||||||
const showPlaceholderContent = timezone === undefined;
|
const showPlaceholder = timezone === undefined;
|
||||||
|
|
||||||
const titleDateFormatted = formatDate(date, undefined, timezone)
|
const titleDateFormatted = formatDate({ date, timezone })
|
||||||
.toLocaleUpperCase();
|
.toLocaleUpperCase();
|
||||||
|
|
||||||
const title = titleLabel
|
const title = titleLabel
|
||||||
? `${titleLabel}: ${titleDateFormatted}`
|
? `${titleLabel}: ${titleDateFormatted}`
|
||||||
: 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
title={showPlaceholderContent ? 'LOADING LOCAL TIME' : title}
|
title={showPlaceholder ? 'LOADING LOCAL TIME' : title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'uppercase rounded-md transition-colors',
|
'uppercase rounded-md transition-colors',
|
||||||
showPlaceholderContent && 'bg-dim',
|
showPlaceholder && 'bg-dim',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -49,20 +58,20 @@ export default function ResponsiveDate({
|
|||||||
className={clsx('xs:hidden', contentClass)}
|
className={clsx('xs:hidden', contentClass)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{formatDate(date, 'short', timezone, showPlaceholderContent)}
|
{formatDate({ ...formatDateProps, length: 'short' })}
|
||||||
</span>
|
</span>
|
||||||
{/* Medium */}
|
{/* Medium */}
|
||||||
<span
|
<span
|
||||||
className={clsx('hidden xs:inline-block sm:hidden', contentClass)}
|
className={clsx('hidden xs:inline-block sm:hidden', contentClass)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{formatDate(date, 'medium', timezone,showPlaceholderContent)}
|
{formatDate({ ...formatDateProps, length: 'medium' })}
|
||||||
</span>
|
</span>
|
||||||
{/* Large */}
|
{/* Large */}
|
||||||
<span
|
<span
|
||||||
className={clsx('hidden sm:inline-block', contentClass)}
|
className={clsx('hidden sm:inline-block', contentClass)}
|
||||||
>
|
>
|
||||||
{formatDate(date, undefined, timezone, showPlaceholderContent)}
|
{formatDate(formatDateProps)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
||||||
import { JSX } from 'react';
|
import { JSX } from 'react';
|
||||||
|
import Spinner from './Spinner';
|
||||||
|
import LinkWithLoader from './LinkWithLoader';
|
||||||
|
|
||||||
export default function SwitcherItem({
|
export default function SwitcherItem({
|
||||||
icon,
|
icon,
|
||||||
@ -44,9 +45,15 @@ export default function SwitcherItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
href
|
href
|
||||||
? <Link {...{ title, href, className, prefetch }}>
|
? <LinkWithLoader {...{
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
prefetch,
|
||||||
|
loader: <Spinner />,
|
||||||
|
}}>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
</Link>
|
</LinkWithLoader>
|
||||||
: <div {...{ title, onClick, className }}>{renderIcon()}</div>
|
: <div {...{ title, onClick, className }}>{renderIcon()}</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,11 +8,13 @@ export default function PhotoDate({
|
|||||||
className,
|
className,
|
||||||
dateType = 'takenAt',
|
dateType = 'takenAt',
|
||||||
timezone,
|
timezone,
|
||||||
|
hideTime,
|
||||||
}: {
|
}: {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
className?: string
|
className?: string
|
||||||
dateType?: 'takenAt' | 'createdAt' | 'updatedAt'
|
dateType?: 'takenAt' | 'createdAt' | 'updatedAt'
|
||||||
timezone: Timezone
|
timezone: Timezone
|
||||||
|
hideTime?: boolean
|
||||||
}) {
|
}) {
|
||||||
const date = useMemo(() => {
|
const date = useMemo(() => {
|
||||||
const date = new Date(dateType === 'takenAt'
|
const date = new Date(dateType === 'takenAt'
|
||||||
@ -45,6 +47,7 @@ export default function PhotoDate({
|
|||||||
className,
|
className,
|
||||||
titleLabel: getTitleLabel(),
|
titleLabel: getTitleLabel(),
|
||||||
timezone,
|
timezone,
|
||||||
|
hideTime,
|
||||||
}} />
|
}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function PhotoEscapeHandler() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldRespondToKeyboardCommands) {
|
if (shouldRespondToKeyboardCommands) {
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.key.toUpperCase() === 'ESCAPE' && escapePath) {
|
if (e.key?.toUpperCase() === 'ESCAPE' && escapePath) {
|
||||||
router.push(escapePath, { scroll: false });
|
router.push(escapePath, { scroll: false });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import PhotoGridSidebar from './PhotoGridSidebar';
|
|||||||
import PhotoGridContainer from './PhotoGridContainer';
|
import PhotoGridContainer from './PhotoGridContainer';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
|
import clsx from 'clsx/lite';
|
||||||
|
|
||||||
export default function PhotoGridPage({
|
export default function PhotoGridPage({
|
||||||
photos,
|
photos,
|
||||||
@ -35,14 +36,19 @@ export default function PhotoGridPage({
|
|||||||
cacheKey={`page-${PATH_GRID}`}
|
cacheKey={`page-${PATH_GRID}`}
|
||||||
photos={photos}
|
photos={photos}
|
||||||
count={photosCount}
|
count={photosCount}
|
||||||
sidebar={<div className="sticky top-4 space-y-4 mt-[-4px]">
|
sidebar={
|
||||||
<PhotoGridSidebar {...{
|
<div className={clsx(
|
||||||
tags,
|
'sticky top-0 -mt-5',
|
||||||
cameras,
|
'max-h-screen overflow-y-auto py-4',
|
||||||
simulations,
|
'[scrollbar-width:none]',
|
||||||
photosCount,
|
)}>
|
||||||
}} />
|
<PhotoGridSidebar {...{
|
||||||
</div>}
|
tags,
|
||||||
|
cameras,
|
||||||
|
simulations,
|
||||||
|
photosCount,
|
||||||
|
}} />
|
||||||
|
</div>}
|
||||||
canSelect
|
canSelect
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export default function PhotoGridSidebar({
|
|||||||
, [tags, hiddenPhotosCount]);
|
, [tags, hiddenPhotosCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
{SITE_ABOUT && <HeaderList
|
{SITE_ABOUT && <HeaderList
|
||||||
items={[<p
|
items={[<p
|
||||||
key="about"
|
key="about"
|
||||||
@ -143,6 +143,6 @@ export default function PhotoGridSidebar({
|
|||||||
: <HeaderList
|
: <HeaderList
|
||||||
items={[photoQuantityText(photosCount, false)]}
|
items={[photoQuantityText(photosCount, false)]}
|
||||||
/>}
|
/>}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import PhotoLink from './PhotoLink';
|
|||||||
import {
|
import {
|
||||||
SHOULD_PREFETCH_ALL_LINKS,
|
SHOULD_PREFETCH_ALL_LINKS,
|
||||||
ALLOW_PUBLIC_DOWNLOADS,
|
ALLOW_PUBLIC_DOWNLOADS,
|
||||||
|
SHOW_TAKEN_AT_TIME,
|
||||||
} from '@/site/config';
|
} from '@/site/config';
|
||||||
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
|
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
|
||||||
import { RevalidatePhoto } from './InfinitePhotoScroll';
|
import { RevalidatePhoto } from './InfinitePhotoScroll';
|
||||||
@ -251,8 +252,10 @@ export default function PhotoLarge({
|
|||||||
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
|
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
|
||||||
)}
|
)}
|
||||||
// Created at is a naive datetime which
|
// 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}
|
timezone={null}
|
||||||
|
hideTime={!SHOW_TAKEN_AT_TIME}
|
||||||
/>
|
/>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex gap-1 translate-y-[0.5px]',
|
'flex gap-1 translate-y-[0.5px]',
|
||||||
|
|||||||
@ -7,12 +7,13 @@ import {
|
|||||||
doesPhotoNeedBlurCompatibility,
|
doesPhotoNeedBlurCompatibility,
|
||||||
} from '.';
|
} from '.';
|
||||||
import ImageMedium from '@/components/image/ImageMedium';
|
import ImageMedium from '@/components/image/ImageMedium';
|
||||||
import Link from 'next/link';
|
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { pathForPhoto } from '@/site/paths';
|
import { pathForPhoto } from '@/site/paths';
|
||||||
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import useOnVisible from '@/utility/useOnVisible';
|
import useOnVisible from '@/utility/useOnVisible';
|
||||||
|
import LinkWithStatus from '@/components/LinkWithStatus';
|
||||||
|
import Spinner from '@/components/Spinner';
|
||||||
|
|
||||||
export default function PhotoMedium({
|
export default function PhotoMedium({
|
||||||
photo,
|
photo,
|
||||||
@ -38,7 +39,7 @@ export default function PhotoMedium({
|
|||||||
useOnVisible(ref, onVisible);
|
useOnVisible(ref, onVisible);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<LinkWithStatus
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={pathForPhoto({ photo, tag, camera, simulation, focal })}
|
href={pathForPhoto({ photo, tag, camera, simulation, focal })}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -48,16 +49,28 @@ export default function PhotoMedium({
|
|||||||
)}
|
)}
|
||||||
prefetch={prefetch}
|
prefetch={prefetch}
|
||||||
>
|
>
|
||||||
<ImageMedium
|
{({ isLoading }) =>
|
||||||
src={photo.url}
|
<div>
|
||||||
aspectRatio={photo.aspectRatio}
|
{isLoading &&
|
||||||
blurDataURL={photo.blurData}
|
<div className={clsx(
|
||||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
'absolute inset-0 flex items-center justify-center',
|
||||||
className="flex object-cover w-full h-full"
|
'text-white bg-black/25 backdrop-blur-sm',
|
||||||
imgClassName="object-cover w-full h-full"
|
'animate-fade-in',
|
||||||
alt={altTextForPhoto(photo)}
|
'z-10',
|
||||||
priority={priority}
|
)}>
|
||||||
/>
|
<Spinner size={20} color="text" />
|
||||||
</Link>
|
</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,
|
convertTimestampWithOffsetToPostgresString,
|
||||||
generateLocalNaivePostgresString,
|
generateLocalNaivePostgresString,
|
||||||
generateLocalPostgresString,
|
generateLocalPostgresString,
|
||||||
|
validationMessageNaivePostgresDateString,
|
||||||
|
validationMessagePostgresDateString,
|
||||||
} from '@/utility/date';
|
} from '@/utility/date';
|
||||||
import {
|
import {
|
||||||
convertApertureValueToFNumber,
|
convertApertureValueToFNumber,
|
||||||
@ -116,8 +118,14 @@ const FORM_METADATA = (
|
|||||||
locationName: { label: 'location name', hide: true },
|
locationName: { label: 'location name', hide: true },
|
||||||
latitude: { label: 'latitude' },
|
latitude: { label: 'latitude' },
|
||||||
longitude: { label: 'longitude' },
|
longitude: { label: 'longitude' },
|
||||||
takenAt: { label: 'taken at' },
|
takenAt: {
|
||||||
takenAtNaive: { label: 'taken at (naive)' },
|
label: 'taken at',
|
||||||
|
validate: validationMessagePostgresDateString,
|
||||||
|
},
|
||||||
|
takenAtNaive: {
|
||||||
|
label: 'taken at (naive)',
|
||||||
|
validate: validationMessageNaivePostgresDateString,
|
||||||
|
},
|
||||||
priorityOrder: { label: 'priority order' },
|
priorityOrder: { label: 'priority order' },
|
||||||
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
|
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
|
||||||
hidden: { label: 'hidden', type: 'checkbox' },
|
hidden: { label: 'hidden', type: 'checkbox' },
|
||||||
|
|||||||
@ -27,13 +27,13 @@ export const INFINITE_SCROLL_FEED_MULTIPLE =
|
|||||||
|
|
||||||
// INFINITE SCROLL: GRID
|
// INFINITE SCROLL: GRID
|
||||||
export const INFINITE_SCROLL_GRID_INITIAL = HIGH_DENSITY_GRID
|
export const INFINITE_SCROLL_GRID_INITIAL = HIGH_DENSITY_GRID
|
||||||
? process.env.NODE_ENV === 'development' ? 12 : 24
|
? process.env.NODE_ENV === 'development' ? 12 : 48
|
||||||
: process.env.NODE_ENV === 'development' ? 12 : 24;
|
: process.env.NODE_ENV === 'development' ? 12 : 48;
|
||||||
export const INFINITE_SCROLL_GRID_MULTIPLE = HIGH_DENSITY_GRID
|
export const INFINITE_SCROLL_GRID_MULTIPLE = HIGH_DENSITY_GRID
|
||||||
? process.env.NODE_ENV === 'development' ? 12 : 48
|
? process.env.NODE_ENV === 'development' ? 12 : 48
|
||||||
: 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 RELATED_GRID_PHOTOS_TO_SHOW = 12;
|
||||||
|
|
||||||
export const DEFAULT_ASPECT_RATIO = 1.5;
|
export const DEFAULT_ASPECT_RATIO = 1.5;
|
||||||
@ -212,7 +212,10 @@ export const titleForPhoto = (
|
|||||||
if (photo.title) {
|
if (photo.title) {
|
||||||
return photo.title;
|
return photo.title;
|
||||||
} else if (preferDateOverUntitled && (photo.takenAt || photo.createdAt)) {
|
} else if (preferDateOverUntitled && (photo.takenAt || photo.createdAt)) {
|
||||||
return formatDate(photo.takenAt || photo.createdAt, 'tiny');
|
return formatDate({
|
||||||
|
date: photo.takenAt || photo.createdAt,
|
||||||
|
length: 'tiny',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
return 'Untitled';
|
return 'Untitled';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { ExifData, ExifParserFactory } from 'ts-exif-parser';
|
|||||||
import { PhotoFormData } from './form';
|
import { PhotoFormData } from './form';
|
||||||
import { FilmSimulation } from '@/simulation';
|
import { FilmSimulation } from '@/simulation';
|
||||||
import sharp, { Sharp } from 'sharp';
|
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_RESIZE = 200;
|
||||||
const IMAGE_WIDTH_BLUR = 200;
|
const IMAGE_WIDTH_BLUR = 200;
|
||||||
@ -29,6 +29,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
imageResizedBase64?: string
|
imageResizedBase64?: string
|
||||||
shouldStripGpsData?: boolean
|
shouldStripGpsData?: boolean
|
||||||
fileBytes?: ArrayBuffer
|
fileBytes?: ArrayBuffer
|
||||||
|
error?: string
|
||||||
}> => {
|
}> => {
|
||||||
const {
|
const {
|
||||||
includeInitialPhotoFields,
|
includeInitialPhotoFields,
|
||||||
@ -42,49 +43,60 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
|
|
||||||
const extension = getExtensionFromStorageUrl(url);
|
const extension = getExtensionFromStorageUrl(url);
|
||||||
|
|
||||||
const fileBytes = blobPath
|
|
||||||
? await fetch(url, { cache: 'no-store' }).then(res => res.arrayBuffer())
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
let exifData: ExifData | undefined;
|
let exifData: ExifData | undefined;
|
||||||
let filmSimulation: FilmSimulation | undefined;
|
let filmSimulation: FilmSimulation | undefined;
|
||||||
let blurData: string | undefined;
|
let blurData: string | undefined;
|
||||||
let imageResizedBase64: string | undefined;
|
let imageResizedBase64: string | undefined;
|
||||||
let shouldStripGpsData = false;
|
let shouldStripGpsData = false;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
if (fileBytes) {
|
const fileBytes = blobPath
|
||||||
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
|
? 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
|
try {
|
||||||
parser.enableBinaryFields(false);
|
if (fileBytes) {
|
||||||
exifData = parser.parse();
|
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
|
||||||
|
|
||||||
// Capture film simulation for Fujifilm cameras
|
// Data for form
|
||||||
if (isExifForFujifilm(exifData)) {
|
parser.enableBinaryFields(false);
|
||||||
// Parse exif data again with binary fields
|
exifData = parser.parse();
|
||||||
// in order to access MakerNote tag
|
|
||||||
parser.enableBinaryFields(true);
|
// Capture film simulation for Fujifilm cameras
|
||||||
const exifDataBinary = parser.parse();
|
if (isExifForFujifilm(exifData)) {
|
||||||
const makerNote = exifDataBinary.tags?.MakerNote;
|
// Parse exif data again with binary fields
|
||||||
if (Buffer.isBuffer(makerNote)) {
|
// in order to access MakerNote tag
|
||||||
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
|
parser.enableBinaryFields(true);
|
||||||
|
const exifDataBinary = parser.parse();
|
||||||
|
const makerNote = exifDataBinary.tags?.MakerNote;
|
||||||
|
if (Buffer.isBuffer(makerNote)) {
|
||||||
|
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (generateBlurData) {
|
if (generateBlurData) {
|
||||||
blurData = await blurImage(fileBytes);
|
blurData = await blurImage(fileBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generateResizedImage) {
|
if (generateResizedImage) {
|
||||||
imageResizedBase64 = await resizeImage(fileBytes);
|
imageResizedBase64 = await resizeImage(fileBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
|
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
|
||||||
Boolean(exifData.tags?.GPSLatitude) ||
|
Boolean(exifData.tags?.GPSLatitude) ||
|
||||||
Boolean(exifData.tags?.GPSLongitude)
|
Boolean(exifData.tags?.GPSLongitude)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = `Error extracting image data from ${url}: "${e}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) { console.log(error); }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blobId,
|
blobId,
|
||||||
...exifData && {
|
...exifData && {
|
||||||
@ -102,6 +114,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
imageResizedBase64,
|
imageResizedBase64,
|
||||||
shouldStripGpsData,
|
shouldStripGpsData,
|
||||||
fileBytes,
|
fileBytes,
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -169,5 +182,5 @@ export const removeGpsData = async (image: ArrayBuffer) =>
|
|||||||
GPSHPositioningError: GPS_NULL_STRING,
|
GPSHPositioningError: GPS_NULL_STRING,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.toFormat('jpeg', { quality: PRO_MODE_ENABLED ? 95 : 80 })
|
.toFormat('jpeg', { quality: PRESERVE_ORIGINAL_UPLOADS ? 95 : 80 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|||||||
@ -6,13 +6,14 @@ import {
|
|||||||
CopyObjectCommand,
|
CopyObjectCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { StorageListResponse, generateStorageId } from '.';
|
import { StorageListResponse, generateStorageId } from '.';
|
||||||
|
import { removeUrlProtocol } from '@/utility/url';
|
||||||
|
|
||||||
const CLOUDFLARE_R2_BUCKET =
|
const CLOUDFLARE_R2_BUCKET =
|
||||||
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? '';
|
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? '';
|
||||||
const CLOUDFLARE_R2_ACCOUNT_ID =
|
const CLOUDFLARE_R2_ACCOUNT_ID =
|
||||||
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? '';
|
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? '';
|
||||||
const CLOUDFLARE_R2_PUBLIC_DOMAIN =
|
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 =
|
const CLOUDFLARE_R2_ACCESS_KEY =
|
||||||
process.env.CLOUDFLARE_R2_ACCESS_KEY ?? '';
|
process.env.CLOUDFLARE_R2_ACCESS_KEY ?? '';
|
||||||
const CLOUDFLARE_R2_SECRET_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 { SHOW_SOCIAL } from '@/site/config';
|
||||||
import { generateXPostText } from '@/utility/social';
|
import { generateXPostText } from '@/utility/social';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
|
import useOnPathChange from '@/utility/useOnPathChange';
|
||||||
|
|
||||||
export default function ShareModal({
|
export default function ShareModal({
|
||||||
title,
|
title,
|
||||||
@ -44,6 +45,8 @@ export default function ShareModal({
|
|||||||
{icon}
|
{icon}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
|
useOnPathChange(() => setShareModalProps?.(undefined));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onClose={() => setShareModalProps?.(undefined)}>
|
<Modal onClose={() => setShareModalProps?.(undefined)}>
|
||||||
<div className="space-y-3 md:space-y-4 w-full">
|
<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 { FiExternalLink } from 'react-icons/fi';
|
||||||
import {
|
import {
|
||||||
BiCog,
|
BiCog,
|
||||||
BiCopy,
|
|
||||||
BiData,
|
BiData,
|
||||||
|
BiHide,
|
||||||
BiLockAlt,
|
BiLockAlt,
|
||||||
BiPencil,
|
BiPencil,
|
||||||
} from 'react-icons/bi';
|
} from 'react-icons/bi';
|
||||||
import Container from '@/components/Container';
|
|
||||||
import Checklist from '@/components/Checklist';
|
import Checklist from '@/components/Checklist';
|
||||||
import { toastSuccess } from '@/toast';
|
|
||||||
import { ConfigChecklistStatus } from './config';
|
import { ConfigChecklistStatus } from './config';
|
||||||
import StatusIcon from '@/components/StatusIcon';
|
import StatusIcon from '@/components/StatusIcon';
|
||||||
import { labelForStorage } from '@/services/storage';
|
import { labelForStorage } from '@/services/storage';
|
||||||
import { HiSparkles } from 'react-icons/hi';
|
import { HiSparkles } from 'react-icons/hi';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
|
||||||
import { testConnectionsAction } from '@/admin/actions';
|
import { testConnectionsAction } from '@/admin/actions';
|
||||||
import ErrorNote from '@/components/ErrorNote';
|
import ErrorNote from '@/components/ErrorNote';
|
||||||
import Spinner from '@/components/Spinner';
|
|
||||||
import WarningNote from '@/components/WarningNote';
|
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({
|
export default function SiteChecklistClient({
|
||||||
// Config checklist
|
// Storage
|
||||||
hasDatabase,
|
hasDatabase,
|
||||||
isPostgresSslEnabled,
|
isPostgresSslEnabled,
|
||||||
hasVercelPostgres,
|
hasVercelPostgres,
|
||||||
@ -39,38 +39,51 @@ export default function SiteChecklistClient({
|
|||||||
hasAwsS3Storage,
|
hasAwsS3Storage,
|
||||||
hasMultipleStorageProviders,
|
hasMultipleStorageProviders,
|
||||||
currentStorage,
|
currentStorage,
|
||||||
|
// Auth
|
||||||
hasAuthSecret,
|
hasAuthSecret,
|
||||||
hasAdminUser,
|
hasAdminUser,
|
||||||
|
// Content
|
||||||
hasDomain,
|
hasDomain,
|
||||||
hasTitle,
|
hasTitle,
|
||||||
hasDescription,
|
hasDescription,
|
||||||
hasAbout,
|
hasAbout,
|
||||||
hasDefaultTheme,
|
// AI
|
||||||
showRepoLink,
|
|
||||||
showSocial,
|
|
||||||
showFilmSimulations,
|
|
||||||
showExifInfo,
|
|
||||||
defaultTheme,
|
|
||||||
isProModeEnabled,
|
|
||||||
isGridHomepageEnabled,
|
|
||||||
isStaticallyOptimized,
|
|
||||||
arePagesStaticallyOptimized,
|
|
||||||
areOGImagesStaticallyOptimized,
|
|
||||||
arePhotosMatted,
|
|
||||||
isBlurEnabled,
|
|
||||||
isGeoPrivacyEnabled,
|
|
||||||
isPriorityOrderEnabled,
|
|
||||||
isAiTextGenerationEnabled,
|
isAiTextGenerationEnabled,
|
||||||
aiTextAutoGeneratedFields,
|
aiTextAutoGeneratedFields,
|
||||||
hasAiTextAutoGeneratedFields,
|
hasAiTextAutoGeneratedFields,
|
||||||
isPublicApiEnabled,
|
// Performance
|
||||||
arePublicDownloadsEnabled,
|
isStaticallyOptimized,
|
||||||
isOgTextBottomAligned,
|
arePhotosStaticallyOptimized,
|
||||||
isImageActionsEnabled,
|
arePhotoOGImagesStaticallyOptimized,
|
||||||
|
arePhotoCategoriesStaticallyOptimized,
|
||||||
|
areOriginalUploadsPreserved,
|
||||||
|
isBlurEnabled,
|
||||||
|
// Display
|
||||||
|
showExifInfo,
|
||||||
|
showTakenAtTimeHidden,
|
||||||
|
showSocial,
|
||||||
|
showFilmSimulations,
|
||||||
|
showRepoLink,
|
||||||
|
// Settings
|
||||||
|
isGridHomepageEnabled,
|
||||||
|
hasDefaultTheme,
|
||||||
|
defaultTheme,
|
||||||
|
arePhotosMatted,
|
||||||
|
isGeoPrivacyEnabled,
|
||||||
gridAspectRatio,
|
gridAspectRatio,
|
||||||
hasGridAspectRatio,
|
hasGridAspectRatio,
|
||||||
gridDensity,
|
gridDensity,
|
||||||
hasGridDensityPreference,
|
hasGridDensityPreference,
|
||||||
|
arePublicDownloadsEnabled,
|
||||||
|
isPublicApiEnabled,
|
||||||
|
isPriorityOrderEnabled,
|
||||||
|
isOgTextBottomAligned,
|
||||||
|
isImageActionsEnabled,
|
||||||
|
// Misc
|
||||||
|
baseUrl,
|
||||||
|
commitSha,
|
||||||
|
commitMessage,
|
||||||
|
commitUrl,
|
||||||
// Connection status
|
// Connection status
|
||||||
databaseError,
|
databaseError,
|
||||||
storageError,
|
storageError,
|
||||||
@ -79,15 +92,10 @@ export default function SiteChecklistClient({
|
|||||||
// Component props
|
// Component props
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
isTestingConnections,
|
isTestingConnections,
|
||||||
secret,
|
|
||||||
baseUrl,
|
|
||||||
commitSha,
|
|
||||||
commitMessage,
|
|
||||||
}: ConfigChecklistStatus &
|
}: ConfigChecklistStatus &
|
||||||
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
||||||
simplifiedView?: boolean
|
simplifiedView?: boolean
|
||||||
isTestingConnections?: boolean
|
isTestingConnections?: boolean
|
||||||
secret?: string
|
|
||||||
}) {
|
}) {
|
||||||
const renderLink = (href: string, text: string, external = true) =>
|
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 = (
|
const renderEnvVar = (
|
||||||
variable: string,
|
variable: string,
|
||||||
minimal?: boolean,
|
minimal?: boolean,
|
||||||
@ -147,7 +138,7 @@ export default function SiteChecklistClient({
|
|||||||
)}>
|
)}>
|
||||||
`{variable}`
|
`{variable}`
|
||||||
</span>
|
</span>
|
||||||
{!minimal && renderCopyButton(variable, variable, true)}
|
{!minimal && <CopyButton label={variable} text={variable} subtle />}
|
||||||
</span>
|
</span>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
@ -169,6 +160,16 @@ export default function SiteChecklistClient({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
|
const renderSubStatusWithEnvVar = (
|
||||||
|
type: ComponentProps<typeof StatusIcon>['type'],
|
||||||
|
variable: string,
|
||||||
|
) =>
|
||||||
|
renderSubStatus(
|
||||||
|
type,
|
||||||
|
renderEnvVars([variable]),
|
||||||
|
'translate-y-[5px]',
|
||||||
|
);
|
||||||
|
|
||||||
const renderError = ({
|
const renderError = ({
|
||||||
connection,
|
connection,
|
||||||
@ -301,18 +302,7 @@ export default function SiteChecklistClient({
|
|||||||
Store auth secret in environment variable:
|
Store auth secret in environment variable:
|
||||||
{!hasAuthSecret &&
|
{!hasAuthSecret &&
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Container className="my-1.5 inline-flex" padding="tight">
|
<SecretGenerator />
|
||||||
<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>
|
|
||||||
</div>}
|
</div>}
|
||||||
{renderEnvVars(['AUTH_SECRET'])}
|
{renderEnvVars(['AUTH_SECRET'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
@ -426,6 +416,102 @@ export default function SiteChecklistClient({
|
|||||||
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
|
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</Checklist>
|
</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
|
<Checklist
|
||||||
title="Settings"
|
title="Settings"
|
||||||
icon={<BiCog size={16} />}
|
icon={<BiCog size={16} />}
|
||||||
@ -452,34 +538,6 @@ export default function SiteChecklistClient({
|
|||||||
(defaults to {'\'system\''}):
|
(defaults to {'\'system\''}):
|
||||||
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
|
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Photo matting"
|
title="Photo matting"
|
||||||
status={arePhotosMatted}
|
status={arePhotosMatted}
|
||||||
@ -490,15 +548,6 @@ export default function SiteChecklistClient({
|
|||||||
of each photo, and enable a surrounding border:
|
of each photo, and enable a surrounding border:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_MATTE_PHOTOS'])}
|
{renderEnvVars(['NEXT_PUBLIC_MATTE_PHOTOS'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Geo privacy"
|
title="Geo privacy"
|
||||||
status={isGeoPrivacyEnabled}
|
status={isGeoPrivacyEnabled}
|
||||||
@ -509,12 +558,24 @@ export default function SiteChecklistClient({
|
|||||||
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Show repo link"
|
title={`Grid aspect ratio: ${gridAspectRatio}`}
|
||||||
status={showRepoLink}
|
status={hasGridAspectRatio}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to hide footer link:
|
Set environment variable to any number to enforce aspect ratio
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
|
{' '}
|
||||||
|
(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>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Public downloads"
|
title="Public downloads"
|
||||||
@ -543,54 +604,6 @@ export default function SiteChecklistClient({
|
|||||||
priority order photo field affecting photo order:
|
priority order photo field affecting photo order:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
|
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Legacy OG text alignment"
|
title="Legacy OG text alignment"
|
||||||
status={isOgTextBottomAligned}
|
status={isOgTextBottomAligned}
|
||||||
@ -630,7 +643,15 @@ export default function SiteChecklistClient({
|
|||||||
<span className="font-bold">Commit</span>
|
<span className="font-bold">Commit</span>
|
||||||
|
|
||||||
{commitSha
|
{commitSha
|
||||||
? <span title={commitMessage}>{commitSha}</span>
|
? commitUrl
|
||||||
|
? <Link
|
||||||
|
title={commitMessage}
|
||||||
|
href={commitUrl}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{commitSha}
|
||||||
|
</Link>
|
||||||
|
: <span title={commitMessage}>{commitSha}</span>
|
||||||
: 'Not Found'}
|
: 'Not Found'}
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { generateAuthSecret } from '@/auth';
|
|
||||||
import SiteChecklistClient from './SiteChecklistClient';
|
import SiteChecklistClient from './SiteChecklistClient';
|
||||||
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
|
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
|
||||||
import { testConnectionsAction } from '@/admin/actions';
|
import { testConnectionsAction } from '@/admin/actions';
|
||||||
@ -8,14 +7,12 @@ export default async function SiteChecklistServer({
|
|||||||
}: {
|
}: {
|
||||||
simplifiedView?: boolean
|
simplifiedView?: boolean
|
||||||
}) {
|
}) {
|
||||||
const secret = await generateAuthSecret().catch(() => 'TRY AGAIN');
|
|
||||||
const connectionErrors = await testConnectionsAction().catch(() => ({}));
|
const connectionErrors = await testConnectionsAction().catch(() => ({}));
|
||||||
return (
|
return (
|
||||||
<SiteChecklistClient {...{
|
<SiteChecklistClient {...{
|
||||||
...CONFIG_CHECKLIST_STATUS,
|
...CONFIG_CHECKLIST_STATUS,
|
||||||
...connectionErrors,
|
...connectionErrors,
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
secret,
|
|
||||||
}} />
|
}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,23 @@ export const SITE_TITLE =
|
|||||||
'Photo Blog';
|
'Photo Blog';
|
||||||
|
|
||||||
// SOURCE
|
// 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;
|
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;
|
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_ENV = process.env.NEXT_PUBLIC_VERCEL_ENV;
|
||||||
const VERCEL_PRODUCTION_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL;
|
const VERCEL_PRODUCTION_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL;
|
||||||
@ -122,63 +135,85 @@ export const CURRENT_STORAGE: StorageType =
|
|||||||
: 'vercel-blob'
|
: '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
|
// SETTINGS
|
||||||
|
|
||||||
|
export const GRID_HOMEPAGE_ENABLED =
|
||||||
|
process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1';
|
||||||
export const DEFAULT_THEME =
|
export const DEFAULT_THEME =
|
||||||
process.env.NEXT_PUBLIC_DEFAULT_THEME === 'dark'
|
process.env.NEXT_PUBLIC_DEFAULT_THEME === 'dark'
|
||||||
? 'dark'
|
? 'dark'
|
||||||
: process.env.NEXT_PUBLIC_DEFAULT_THEME === 'light'
|
: process.env.NEXT_PUBLIC_DEFAULT_THEME === 'light'
|
||||||
? 'light'
|
? 'light'
|
||||||
: 'system';
|
: '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 =
|
export const MATTE_PHOTOS =
|
||||||
process.env.NEXT_PUBLIC_MATTE_PHOTOS === '1';
|
process.env.NEXT_PUBLIC_MATTE_PHOTOS === '1';
|
||||||
export const BLUR_ENABLED =
|
|
||||||
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
|
|
||||||
export const GEO_PRIVACY_ENABLED =
|
export const GEO_PRIVACY_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
|
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 =
|
export const GRID_ASPECT_RATIO =
|
||||||
process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO
|
process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO
|
||||||
? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO)
|
? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO)
|
||||||
: 1;
|
: 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 =
|
export const PREFERS_LOW_DENSITY_GRID =
|
||||||
process.env.NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS === '1';
|
process.env.NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS === '1';
|
||||||
export const HIGH_DENSITY_GRID =
|
export const HIGH_DENSITY_GRID =
|
||||||
GRID_ASPECT_RATIO <= 1 &&
|
GRID_ASPECT_RATIO <= 1 &&
|
||||||
!PREFERS_LOW_DENSITY_GRID;
|
!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 = {
|
export const CONFIG_CHECKLIST_STATUS = {
|
||||||
|
// STORAGE
|
||||||
hasDatabase: HAS_DATABASE,
|
hasDatabase: HAS_DATABASE,
|
||||||
isPostgresSslEnabled: POSTGRES_SSL_ENABLED,
|
isPostgresSslEnabled: POSTGRES_SSL_ENABLED,
|
||||||
hasVercelPostgres: (
|
hasVercelPostgres: (
|
||||||
@ -196,32 +231,18 @@ export const CONFIG_CHECKLIST_STATUS = {
|
|||||||
),
|
),
|
||||||
hasMultipleStorageProviders: HAS_MULTIPLE_STORAGE_PROVIDERS,
|
hasMultipleStorageProviders: HAS_MULTIPLE_STORAGE_PROVIDERS,
|
||||||
currentStorage: CURRENT_STORAGE,
|
currentStorage: CURRENT_STORAGE,
|
||||||
|
// AUTH
|
||||||
hasAuthSecret: Boolean(process.env.AUTH_SECRET),
|
hasAuthSecret: Boolean(process.env.AUTH_SECRET),
|
||||||
hasAdminUser: (
|
hasAdminUser: (
|
||||||
Boolean(process.env.ADMIN_EMAIL) &&
|
Boolean(process.env.ADMIN_EMAIL) &&
|
||||||
Boolean(process.env.ADMIN_PASSWORD)
|
Boolean(process.env.ADMIN_PASSWORD)
|
||||||
),
|
),
|
||||||
|
// CONTENT
|
||||||
hasDomain: Boolean(process.env.NEXT_PUBLIC_SITE_DOMAIN),
|
hasDomain: Boolean(process.env.NEXT_PUBLIC_SITE_DOMAIN),
|
||||||
hasTitle: Boolean(process.env.NEXT_PUBLIC_SITE_TITLE),
|
hasTitle: Boolean(process.env.NEXT_PUBLIC_SITE_TITLE),
|
||||||
hasDescription: HAS_DEFINED_SITE_DESCRIPTION,
|
hasDescription: HAS_DEFINED_SITE_DESCRIPTION,
|
||||||
hasAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT),
|
hasAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT),
|
||||||
hasDefaultTheme: Boolean(process.env.NEXT_PUBLIC_DEFAULT_THEME),
|
// AI
|
||||||
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,
|
|
||||||
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
|
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
|
||||||
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
|
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
|
||||||
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
|
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
|
||||||
@ -230,19 +251,44 @@ export const CONFIG_CHECKLIST_STATUS = {
|
|||||||
: ['all'],
|
: ['all'],
|
||||||
hasAiTextAutoGeneratedFields:
|
hasAiTextAutoGeneratedFields:
|
||||||
Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS),
|
Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS),
|
||||||
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
|
// PERFORMANCE
|
||||||
isPublicApiEnabled: PUBLIC_API_ENABLED,
|
isStaticallyOptimized: (
|
||||||
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
|
STATICALLY_OPTIMIZED_PHOTOS ||
|
||||||
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
|
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES ||
|
||||||
isImageActionsEnabled: IMAGE_ACTIONS_ENABLED,
|
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,
|
gridAspectRatio: GRID_ASPECT_RATIO,
|
||||||
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
|
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
|
||||||
gridDensity: HIGH_DENSITY_GRID,
|
gridDensity: HIGH_DENSITY_GRID,
|
||||||
hasGridDensityPreference:
|
hasGridDensityPreference:
|
||||||
Boolean(process.env.NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS),
|
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,
|
baseUrl: BASE_URL,
|
||||||
commitSha: VERCEL_COMMIT_SHA ? VERCEL_COMMIT_SHA.slice(0, 7) : undefined,
|
commitSha: VERCEL_GIT_COMMIT_SHA_SHORT,
|
||||||
commitMessage: VERCEL_COMMIT_MESSAGE,
|
commitMessage: VERCEL_GIT_COMMIT_MESSAGE,
|
||||||
|
commitUrl: VERCEL_GIT_COMMIT_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
|
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
[data-sonner-toaster] {
|
[data-sonner-toaster] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
--mobile-offset: 12px !important;
|
--mobile-offset: 12px !important;
|
||||||
|
--mobile-offset-left: var(--mobile-offset) !important;
|
||||||
|
--mobile-offset-right: var(--mobile-offset) !important;
|
||||||
right: var(--mobile-offset);
|
right: var(--mobile-offset);
|
||||||
left: var(--mobile-offset);
|
left: var(--mobile-offset);
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
|||||||
@ -2,56 +2,78 @@ import { parseISO, parse, format } from 'date-fns';
|
|||||||
import { formatInTimeZone } from 'date-fns-tz';
|
import { formatInTimeZone } from 'date-fns-tz';
|
||||||
import { Timezone } from './timezone';
|
import { Timezone } from './timezone';
|
||||||
|
|
||||||
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
|
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
|
||||||
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
|
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
|
||||||
|
|
||||||
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
|
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
|
||||||
const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000';
|
const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000';
|
||||||
|
|
||||||
const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma';
|
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_PLACEHOLDER = '00 000 00 00:0000';
|
||||||
|
|
||||||
const DATE_STRING_FORMAT_LONG = 'dd MMM yyyy h:mma';
|
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_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 AmbiguousTimestamp = number | string;
|
||||||
|
|
||||||
type Length = 'tiny' | 'short' | 'medium' | 'long';
|
type Length = 'tiny' | 'short' | 'medium' | 'long';
|
||||||
|
|
||||||
export const formatDate = (
|
export const formatDate = ({
|
||||||
|
date,
|
||||||
|
length = 'long',
|
||||||
|
timezone,
|
||||||
|
hideTime,
|
||||||
|
showPlaceholder,
|
||||||
|
}: {
|
||||||
date: Date,
|
date: Date,
|
||||||
length: Length = 'long',
|
length?: Length,
|
||||||
timezone?: Timezone,
|
timezone?: Timezone,
|
||||||
|
hideTime?: boolean,
|
||||||
showPlaceholder?: boolean,
|
showPlaceholder?: boolean,
|
||||||
) => {
|
}) => {
|
||||||
switch (length) {
|
let formatString = !hideTime
|
||||||
case 'tiny': return showPlaceholder
|
? DATE_STRING_FORMAT_LONG
|
||||||
? DATE_STRING_FORMAT_TINY_PLACEHOLDER
|
: DATE_STRING_FORMAT_SHORT;
|
||||||
: timezone
|
let placeholderString = !hideTime
|
||||||
? 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
|
|
||||||
? DATE_STRING_FORMAT_LONG_PLACEHOLDER
|
? DATE_STRING_FORMAT_LONG_PLACEHOLDER
|
||||||
: timezone
|
: DATE_STRING_FORMAT_SHORT_PLACEHOLDER;
|
||||||
? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_LONG)
|
|
||||||
: format(date, DATE_STRING_FORMAT_LONG);
|
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) =>
|
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) =>
|
export const formatDateForPostgres = (date: Date) =>
|
||||||
date.toISOString().replace(
|
date.toISOString().replace(
|
||||||
@ -103,3 +125,23 @@ export const generateLocalPostgresString = () =>
|
|||||||
|
|
||||||
export const generateLocalNaivePostgresString = () =>
|
export const generateLocalNaivePostgresString = () =>
|
||||||
format(new Date(), DATE_STRING_FORMAT_POSTGRES);
|
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(/\/$/, '')
|
.replace(/\/$/, '')
|
||||||
: undefined;
|
: 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
|
// Add protocol to url and remove trailing slash
|
||||||
export const makeUrlAbsolute = (url?: string) => url !== undefined
|
export const makeUrlAbsolute = (url?: string) => url !== undefined
|
||||||
? (!url.startsWith('http') ? `https://${url}` : url)
|
? (!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: {
|
animation: {
|
||||||
'rotate-pulse':
|
'rotate-pulse':
|
||||||
'rotate-pulse 0.75s linear infinite normal both running',
|
'rotate-pulse 0.75s linear infinite normal both running',
|
||||||
|
'fade-in':
|
||||||
|
'fade-in 0.5s linear',
|
||||||
'hover-drift':
|
'hover-drift':
|
||||||
'hover-drift 8s linear infinite',
|
'hover-drift 8s linear infinite',
|
||||||
'hover-wobble':
|
'hover-wobble':
|
||||||
'hover-wobble 6s linear infinite normal both running',
|
'hover-wobble 6s linear infinite normal both running',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
'rotate-pulse': {
|
'rotate-pulse': {
|
||||||
'0%': { transform: 'rotate(0deg) scale(1)' },
|
'0%': { transform: 'rotate(0deg) scale(1)' },
|
||||||
'50%': { transform: 'rotate(180deg) scale(0.8)' },
|
'50%': { transform: 'rotate(180deg) scale(0.8)' },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user