Merge branch 'main' into ppr-static

This commit is contained in:
Sam Becker 2024-05-07 10:50:16 -05:00
commit e79a053b6d
50 changed files with 584 additions and 547 deletions

View File

@ -46,6 +46,7 @@
"upstash",
"UsKSGcbt",
"Velvia",
"verceldb",
"WRHGZC",
"wxyz",
"zadd",

View File

@ -105,14 +105,6 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint)
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
## Alternate database providers (experimental)
Vercel Postgres can be switched to another Postgres-compatible, pooling provider by updating `POSTGRES_URL`. Some providers only work when SSL is disabled, which can configured by setting `DISABLE_POSTGRES_SSL = 1`.
### Supabase
1. Ensure connection string is set to "Transaction Mode" via port `6543`
2. Disable SSL by setting `DISABLE_POSTGRES_SSL = 1`
## Alternate storage providers
Only one storage adapter—Vercel Blob, Cloudflare R2, or AWS S3—can be used at a time. Ideally, this is configured before photos are uploaded (see [Issue #34](https://github.com/sambecker/exif-photo-blog/issues/34) for migration considerations). If you have multiple adapters, you can set one as preferred by storing "aws-s3," "cloudflare-r2," or "vercel-blob" in `NEXT_PUBLIC_STORAGE_PREFERENCE`.
@ -201,6 +193,14 @@ Only one storage adapter—Vercel Blob, Cloudflare R2, or AWS S3—can be used a
- `AWS_S3_ACCESS_KEY`
- `AWS_S3_SECRET_ACCESS_KEY`
## Alternate database providers (experimental)
Vercel Postgres can be switched to another Postgres-compatible, pooling provider by updating `POSTGRES_URL`. Some providers only work when SSL is disabled, which can configured by setting `DISABLE_POSTGRES_SSL = 1`.
### Supabase
1. Ensure connection string is set to "Transaction Mode" via port `6543`
2. Disable SSL by setting `DISABLE_POSTGRES_SSL = 1`
FAQ
-
#### Why are my thumbnails square?
@ -220,3 +220,6 @@ FAQ
#### Why do my images appear flipped/rotated incorrectly?
> For a number of reasons, only EXIF orientations: 1, 3, 6, and 8 are supported. Orientations 2, 4, 5, and 7—which make use of mirroring—are not supported.
#### Why does my image placeholder blur look different from photo to photo?
> Earlier versions of this template generated blur data on the client, which varied visually from browser to browser. Data is now generated consistently on the server. If you wish to update blur data for a particular photo, edit the photo in question, make no changes, and choose "Update."

View File

@ -42,7 +42,7 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.7",
"next": "14.3.0-canary.37",
"next": "14.3.0-canary.44",
"next-auth": "5.0.0-beta.15",
"next-themes": "^0.3.0",
"openai": "^4.38.5",
@ -51,6 +51,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "^5.1.0",
"sharp": "^0.33.3",
"sonner": "^1.4.41",
"swr": "^2.2.5",
"tailwindcss": "3.4.3",

156
pnpm-lock.yaml generated
View File

@ -16,7 +16,7 @@ importers:
version: 3.564.0
'@next/bundle-analyzer':
specifier: 14.2.3
version: 14.2.3(bufferutil@4.0.8)(utf-8-validate@6.0.3)
version: 14.2.3(bufferutil@4.0.8)
'@radix-ui/react-dropdown-menu':
specifier: ^2.0.6
version: 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -58,7 +58,7 @@ importers:
version: 1.1.3
'@vercel/analytics':
specifier: ^1.2.2
version: 1.2.2(next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 1.2.2(next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@vercel/blob':
specifier: ^0.23.2
version: 0.23.2
@ -67,7 +67,7 @@ importers:
version: 1.0.1
'@vercel/speed-insights':
specifier: ^1.0.10
version: 1.0.10(next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(svelte@4.2.15)(vue@3.4.25(typescript@5.4.5))
version: 1.0.10(next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(svelte@4.2.15)(vue@3.4.25(typescript@5.4.5))
ai:
specifier: ^3.0.34
version: 3.0.34(react@18.3.1)(solid-js@1.8.17)(svelte@4.2.15)(vue@3.4.25(typescript@5.4.5))(zod@3.23.4)
@ -103,16 +103,16 @@ importers:
version: 29.7.0(@types/node@20.12.7)
jest-environment-jsdom:
specifier: ^29.7.0
version: 29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.3)
version: 29.7.0(bufferutil@4.0.8)
nanoid:
specifier: ^5.0.7
version: 5.0.7
next:
specifier: 14.3.0-canary.37
version: 14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 14.3.0-canary.44
version: 14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-auth:
specifier: 5.0.0-beta.15
version: 5.0.0-beta.15(next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 5.0.0-beta.15(next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -134,6 +134,9 @@ importers:
react-icons:
specifier: ^5.1.0
version: 5.1.0(react@18.3.1)
sharp:
specifier: ^0.33.3
version: 0.33.3
sonner:
specifier: ^1.4.41
version: 1.4.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -806,62 +809,62 @@ packages:
'@next/bundle-analyzer@14.2.3':
resolution: {integrity: sha512-Z88hbbngMs7njZKI8kTJIlpdLKYfMSLwnsqYe54AP4aLmgL70/Ynx/J201DQ+q2Lr6FxFw1uCeLGImDrHOl2ZA==}
'@next/env@14.3.0-canary.37':
resolution: {integrity: sha512-pZMCjC6cG4MEemm3mG+Ac1qzUgAABrqCnB71hjgPXe1adacL/wK7YliSUtoyC9Eurw4Pm0mBi4SCg0z6ymbMVg==}
'@next/env@14.3.0-canary.44':
resolution: {integrity: sha512-n7E0UKB5tAcEEVO9iLuWVdx5nf+39GEHBo4mrRrC9zqXdP9Jxve4nFWFeDTU5EBSzuH3Zy4DmoVNHUOspHLmyQ==}
'@next/eslint-plugin-next@14.2.3':
resolution: {integrity: sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==}
'@next/swc-darwin-arm64@14.3.0-canary.37':
resolution: {integrity: sha512-AMH81oibPiLqhDpSuTinVwO27mvGtXCgq7puAhuFhVTTGfATBh/flwed7utC+/K6bC8dkd5QsRMV4+sxySdLeA==}
'@next/swc-darwin-arm64@14.3.0-canary.44':
resolution: {integrity: sha512-gooP4KXsw3DDZAlvjEJNyQsSacwPRJJ5f5wkrws1J17L/heUgZHX6G7vHnQgkAejyvfa5BhW2c9rlcPxOHNQqw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@14.3.0-canary.37':
resolution: {integrity: sha512-fsFpGflXJyEXwD5k9fjMHOie6hj641hKAn4em8e3ohagH8nnb7OpC2QAtxv9WCkdSnH2Lv3OcLqsL4quieMKmg==}
'@next/swc-darwin-x64@14.3.0-canary.44':
resolution: {integrity: sha512-NbtsRFYzs8sU2VCMzqGjb4tdzhkQt1KcMB/ZqnHX5pPw5xtXqPXzBGLM0z3wHr5/vWlL4V22j8E7AGchE2TeXg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@14.3.0-canary.37':
resolution: {integrity: sha512-+PtZuhB5WwMICXMfmtK6Ax3S+CZkgj2++M/G/8wJfifghoeesVQgmVlFLgVtjApE2wQXwYJture3LlqteeM7vw==}
'@next/swc-linux-arm64-gnu@14.3.0-canary.44':
resolution: {integrity: sha512-ctGyGeHy/07TH82ZYuA74Xy0t8Zcq1xrMLTI9RUA8Dh4khf37RI7r9SHzAeqKmPGZJAPRyE6pwXtAlCg/TstjA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@14.3.0-canary.37':
resolution: {integrity: sha512-tgvJKQq68cGyrsIjm0X5jkGOlHMvJwRfUf7UAHTHcqtbwXF/SLLDkQHlCtnPXgaBYEEIH6GG9loo3+v/nzLzQg==}
'@next/swc-linux-arm64-musl@14.3.0-canary.44':
resolution: {integrity: sha512-cX/jD3EnhIphZwRBureGSPV4GlQ7ueUKmne+5N2BsO6tHih0cnveyssropX1dTupU7aGti+22kPorPDY7BYNfQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@14.3.0-canary.37':
resolution: {integrity: sha512-tmQmL6WJ6DUFOLcIy8BFHwTzjJKLK7xp35OWdoOV/GibL4T9OSDkMAtKxRALvFsQ71LsIF+O69qDrvZ+35i+rA==}
'@next/swc-linux-x64-gnu@14.3.0-canary.44':
resolution: {integrity: sha512-2HPcwOxXBQj3WD5ezz78o/SJXXz9D4U5H7Mc4e0cTUMy/GGW4ysg2Ullwus+FVOmm2z8GAcNbEeqh71D/lAtww==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@14.3.0-canary.37':
resolution: {integrity: sha512-kXs2JVLdL1MWkFKMfVavlRJl9+Ep0xFLyrrJrxxyxBFp2qNXYqKXjEXXqYpKv0btnSHf7Tokt89TZztRxoaJlw==}
'@next/swc-linux-x64-musl@14.3.0-canary.44':
resolution: {integrity: sha512-UV9HUQenKZkrqbhRsB59X5KrMKaPUnXkbZmwLkjD2IieUgm5CswfXJ2+7JneopviwaR3k8eJMA2KV9uyA/3LpQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@14.3.0-canary.37':
resolution: {integrity: sha512-HU18Co5cE7Hgrjwac6CzdtxErJJ1KHVBoVegMpH6BWBjWASv05vM9yK6fJyRsWG1mJu152Ozr0TgpXBOAkA7UQ==}
'@next/swc-win32-arm64-msvc@14.3.0-canary.44':
resolution: {integrity: sha512-Swrl7I7q4sw7iS6O2j5v/c5bLgHP+i4/z79XTzU2LDubMP7gL3eUqLhN1GAOO35q/j49Ysbsr3VUM14JCtGOkg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-ia32-msvc@14.3.0-canary.37':
resolution: {integrity: sha512-sSq2frpcGuriFymNBFyppV7Vli8q6+jEyYBbqJo9QHXj5swvTksirnws9vwPM0PALfM6HPMgA1YkEW3DITWq9Q==}
'@next/swc-win32-ia32-msvc@14.3.0-canary.44':
resolution: {integrity: sha512-3KKbNG6EDMpAY9PP/Bv32ildjljC9vUwRbLrhBLa6l3TkD6n8xtlObNf9DMD0Fsa79VKHZKIkY1EvCn/7xlj5A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@next/swc-win32-x64-msvc@14.3.0-canary.37':
resolution: {integrity: sha512-Qs4JJYMRG7wq/BTDHoNIz8kmbEk4O08dswfgCH3nKbDKCYJ1z/UYedksNrydRFmVyHmYMLyqtxlbUXgPwI02yA==}
'@next/swc-win32-x64-msvc@14.3.0-canary.44':
resolution: {integrity: sha512-pDD9g/tTI/ihOP4c8wy5E0dRz8QUy2/7uAVyW11MIkroA4A4/cWa58PQNW5ByhvySPTcf9trnlaVPa2mdxBMMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -3256,8 +3259,8 @@ packages:
react: ^16.8 || ^17 || ^18
react-dom: ^16.8 || ^17 || ^18
next@14.3.0-canary.37:
resolution: {integrity: sha512-tWIT0Lep3IS+O3RELN4RUWqNHsQeqWJgf43riJgV3mJoWdRSlX610xyif63ZGwaz8Qd6MbvuGbtNWDnWCBZf0w==}
next@14.3.0-canary.44:
resolution: {integrity: sha512-iYJmuiARcldXjN27N0Yo8gWyy6vWl+FoUNcEaL2GGh3sA/rc4hcbZD89ZUXoVFsPAI7ze6sKM60Znct9RwCbKQ==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
@ -4179,10 +4182,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
utf-8-validate@6.0.3:
resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==}
engines: {node: '>=6.14.2'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -5446,44 +5445,44 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
'@next/bundle-analyzer@14.2.3(bufferutil@4.0.8)(utf-8-validate@6.0.3)':
'@next/bundle-analyzer@14.2.3(bufferutil@4.0.8)':
dependencies:
webpack-bundle-analyzer: 4.10.1(bufferutil@4.0.8)(utf-8-validate@6.0.3)
webpack-bundle-analyzer: 4.10.1(bufferutil@4.0.8)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@next/env@14.3.0-canary.37': {}
'@next/env@14.3.0-canary.44': {}
'@next/eslint-plugin-next@14.2.3':
dependencies:
glob: 10.3.10
'@next/swc-darwin-arm64@14.3.0-canary.37':
'@next/swc-darwin-arm64@14.3.0-canary.44':
optional: true
'@next/swc-darwin-x64@14.3.0-canary.37':
'@next/swc-darwin-x64@14.3.0-canary.44':
optional: true
'@next/swc-linux-arm64-gnu@14.3.0-canary.37':
'@next/swc-linux-arm64-gnu@14.3.0-canary.44':
optional: true
'@next/swc-linux-arm64-musl@14.3.0-canary.37':
'@next/swc-linux-arm64-musl@14.3.0-canary.44':
optional: true
'@next/swc-linux-x64-gnu@14.3.0-canary.37':
'@next/swc-linux-x64-gnu@14.3.0-canary.44':
optional: true
'@next/swc-linux-x64-musl@14.3.0-canary.37':
'@next/swc-linux-x64-musl@14.3.0-canary.44':
optional: true
'@next/swc-win32-arm64-msvc@14.3.0-canary.37':
'@next/swc-win32-arm64-msvc@14.3.0-canary.44':
optional: true
'@next/swc-win32-ia32-msvc@14.3.0-canary.37':
'@next/swc-win32-ia32-msvc@14.3.0-canary.44':
optional: true
'@next/swc-win32-x64-msvc@14.3.0-canary.37':
'@next/swc-win32-x64-msvc@14.3.0-canary.44':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -6405,11 +6404,11 @@ snapshots:
dependencies:
crypto-js: 4.2.0
'@vercel/analytics@1.2.2(next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@vercel/analytics@1.2.2(next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
server-only: 0.0.1
optionalDependencies:
next: 14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@vercel/blob@0.23.2':
@ -6423,9 +6422,9 @@ snapshots:
dependencies:
'@upstash/redis': 1.25.1
'@vercel/speed-insights@1.0.10(next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(svelte@4.2.15)(vue@3.4.25(typescript@5.4.5))':
'@vercel/speed-insights@1.0.10(next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(svelte@4.2.15)(vue@3.4.25(typescript@5.4.5))':
optionalDependencies:
next: 14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
svelte: 4.2.15
vue: 3.4.25(typescript@5.4.5)
@ -6896,13 +6895,11 @@ snapshots:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
optional: true
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
optional: true
combined-stream@1.0.8:
dependencies:
@ -7022,8 +7019,7 @@ snapshots:
dequal@2.0.3: {}
detect-libc@2.0.3:
optional: true
detect-libc@2.0.3: {}
detect-newline@3.1.0: {}
@ -7713,8 +7709,7 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.2:
optional: true
is-arrayish@0.3.2: {}
is-async-function@2.0.0:
dependencies:
@ -7982,7 +7977,7 @@ snapshots:
jest-util: 29.7.0
pretty-format: 29.7.0
jest-environment-jsdom@29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.3):
jest-environment-jsdom@29.7.0(bufferutil@4.0.8):
dependencies:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
@ -7991,7 +7986,7 @@ snapshots:
'@types/node': 20.12.7
jest-mock: 29.7.0
jest-util: 29.7.0
jsdom: 20.0.3(bufferutil@4.0.8)(utf-8-validate@6.0.3)
jsdom: 20.0.3(bufferutil@4.0.8)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -8220,7 +8215,7 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@20.0.3(bufferutil@4.0.8)(utf-8-validate@6.0.3):
jsdom@20.0.3(bufferutil@4.0.8):
dependencies:
abab: 2.0.6
acorn: 8.11.3
@ -8246,7 +8241,7 @@ snapshots:
whatwg-encoding: 2.0.0
whatwg-mimetype: 3.0.0
whatwg-url: 11.0.0
ws: 8.16.0(bufferutil@4.0.8)(utf-8-validate@6.0.3)
ws: 8.16.0(bufferutil@4.0.8)
xml-name-validator: 4.0.0
transitivePeerDependencies:
- bufferutil
@ -8412,10 +8407,10 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.15(next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
next-auth@5.0.0-beta.15(next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@auth/core': 0.28.0
next: 14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
next-themes@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@ -8423,9 +8418,9 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
next@14.3.0-canary.37(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
next@14.3.0-canary.44(@babel/core@7.24.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.3.0-canary.37
'@next/env': 14.3.0-canary.44
'@swc/helpers': 0.5.11
busboy: 1.6.0
caniuse-lite: 1.0.30001612
@ -8435,15 +8430,15 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.1(@babel/core@7.24.4)(react@18.3.1)
optionalDependencies:
'@next/swc-darwin-arm64': 14.3.0-canary.37
'@next/swc-darwin-x64': 14.3.0-canary.37
'@next/swc-linux-arm64-gnu': 14.3.0-canary.37
'@next/swc-linux-arm64-musl': 14.3.0-canary.37
'@next/swc-linux-x64-gnu': 14.3.0-canary.37
'@next/swc-linux-x64-musl': 14.3.0-canary.37
'@next/swc-win32-arm64-msvc': 14.3.0-canary.37
'@next/swc-win32-ia32-msvc': 14.3.0-canary.37
'@next/swc-win32-x64-msvc': 14.3.0-canary.37
'@next/swc-darwin-arm64': 14.3.0-canary.44
'@next/swc-darwin-x64': 14.3.0-canary.44
'@next/swc-linux-arm64-gnu': 14.3.0-canary.44
'@next/swc-linux-arm64-musl': 14.3.0-canary.44
'@next/swc-linux-x64-gnu': 14.3.0-canary.44
'@next/swc-linux-x64-musl': 14.3.0-canary.44
'@next/swc-win32-arm64-msvc': 14.3.0-canary.44
'@next/swc-win32-ia32-msvc': 14.3.0-canary.44
'@next/swc-win32-x64-msvc': 14.3.0-canary.44
sharp: 0.33.3
transitivePeerDependencies:
- '@babel/core'
@ -8982,7 +8977,6 @@ snapshots:
'@img/sharp-wasm32': 0.33.3
'@img/sharp-win32-ia32': 0.33.3
'@img/sharp-win32-x64': 0.33.3
optional: true
shebang-command@2.0.0:
dependencies:
@ -9004,7 +8998,6 @@ snapshots:
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
optional: true
sirv@2.0.4:
dependencies:
@ -9388,11 +9381,6 @@ snapshots:
dependencies:
react: 18.3.1
utf-8-validate@6.0.3:
dependencies:
node-gyp-build: 4.8.0
optional: true
util-deprecate@1.0.2: {}
uuid@9.0.1: {}
@ -9429,7 +9417,7 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-bundle-analyzer@4.10.1(bufferutil@4.0.8)(utf-8-validate@6.0.3):
webpack-bundle-analyzer@4.10.1(bufferutil@4.0.8):
dependencies:
'@discoveryjs/json-ext': 0.5.7
acorn: 8.11.3
@ -9443,7 +9431,7 @@ snapshots:
opener: 1.5.2
picocolors: 1.0.0
sirv: 2.0.4
ws: 7.5.9(bufferutil@4.0.8)(utf-8-validate@6.0.3)
ws: 7.5.9(bufferutil@4.0.8)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@ -9527,15 +9515,13 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 3.0.7
ws@7.5.9(bufferutil@4.0.8)(utf-8-validate@6.0.3):
ws@7.5.9(bufferutil@4.0.8):
optionalDependencies:
bufferutil: 4.0.8
utf-8-validate: 6.0.3
ws@8.16.0(bufferutil@4.0.8)(utf-8-validate@6.0.3):
ws@8.16.0(bufferutil@4.0.8):
optionalDependencies:
bufferutil: 4.0.8
utf-8-validate: 6.0.3
xml-name-validator@4.0.0: {}

View File

@ -1,23 +1,17 @@
import Link from 'next/link';
import { BiImageAdd } from 'react-icons/bi';
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
export default function AddButton ({
href,
label = 'Add',
export default function AddButton({
path,
}: {
href: string,
label?: string,
path: string,
}) {
return (
<Link
title={label}
href={href}
className="button"
<PathLoaderButton
path={path}
icon={<BiImageAdd size={18} className="translate-x-[1px]" />}
>
<BiImageAdd size={18} className="translate-y-[1px]" />
<span className="hidden sm:inline-block">
{label}
</span>
</Link>
Add
</PathLoaderButton>
);
}

View File

@ -21,7 +21,7 @@ import {
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
export default function AdminPhotoTable({
export default function AdminPhotosTable({
photos,
onLastPhotoVisible,
revalidatePhoto,
@ -80,7 +80,7 @@ export default function AdminPhotoTable({
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminPhotoEdit(photo)} />
<EditButton path={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={syncPhotoExifDataAction}
confirmText={
@ -91,7 +91,9 @@ export default function AdminPhotoTable({
>
<input type="hidden" name="id" value={photo.id} />
<SubmitButtonWithStatus
icon={<IconGrSync className="translate-y-[-0.5px]" />}
icon={<IconGrSync
className="translate-x-[1px] translate-y-[0.5px]"
/>}
onFormSubmitToastMessage={`
"${titleForPhoto(photo)}" EXIF data synced
`}

View File

@ -3,9 +3,9 @@
import InfinitePhotoScroll, {
InfinitePhotoScrollExternalProps,
} from '../photo/InfinitePhotoScroll';
import AdminPhotoTable from './AdminPhotoTable';
import AdminPhotosTable from './AdminPhotosTable';
export default function AdminPhotoTableInfinite({
export default function AdminPhotosTableInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
@ -18,7 +18,7 @@ export default function AdminPhotoTableInfinite({
includeHiddenPhotos
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
<AdminPhotoTable
<AdminPhotosTable
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}

View File

@ -26,7 +26,7 @@ export default function AdminTagTable({
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminTagEdit(tag)} />
<EditButton path={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={

View File

@ -11,7 +11,7 @@ import { pathForAdminUploadUrl } from '@/site/paths';
import AddButton from './AddButton';
import { formatDate } from 'date-fns';
export default function StorageUrls({
export default function AdminUploadsTable({
title,
urls,
}: {
@ -49,7 +49,7 @@ export default function StorageUrls({
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<AddButton href={addUploadPath} />
<AddButton path={addUploadPath} />
<FormWithConfirm
action={deleteBlobPhotoAction}
confirmText="Are you sure you want to delete this upload?"

View File

@ -11,7 +11,7 @@ export default function ClearCacheButton() {
return (
<form action={syncCacheAction}>
<SubmitButtonWithStatus
icon={<BiTrash />}
icon={<BiTrash size={16} />}
onFormSubmit={invalidateSwr}
>
Clear Cache

View File

@ -14,6 +14,7 @@ export default function DeleteButton (
const {
onFormSubmit: onFormSubmitProps,
clearLocalState,
className,
...rest
} = props;
@ -30,11 +31,13 @@ export default function DeleteButton (
return <SubmitButtonWithStatus
{...rest}
title="Delete"
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
icon={<BiTrash size={16} />}
spinnerColor="text"
className={clsx(
'text-red-500 dark:text-red-600',
className,
'!text-red-500 dark:!text-red-600',
'active:!bg-red-100/50 active:dark:!bg-red-950/50',
'disabled:!bg-red-100/50 disabled:dark:!bg-red-950/50',
'!border-red-200 hover:!border-red-300',
'dark:!border-red-900/75 dark:hover:!border-red-900',
)}

View File

@ -1,24 +1,17 @@
import Link from 'next/link';
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
import { FaRegEdit } from 'react-icons/fa';
export default function EditButton ({
href,
label = 'Edit',
path,
}: {
href: string,
label?: string,
path: string,
}) {
return (
<Link
title={label}
href={href}
className="button"
prefetch={false}
<PathLoaderButton
path={path}
icon={<FaRegEdit size={15} className="translate-y-[0.5px]" />}
>
<FaRegEdit className="translate-y-[-0.5px]" />
<span className="hidden sm:inline-block">
{label}
</span>
</Link>
Edit
</PathLoaderButton>
);
}

View File

@ -2,7 +2,9 @@ import { redirect } from 'next/navigation';
import { getPhotoNoStore, getUniqueTagsCached } from '@/photo/cache';
import { PATH_ADMIN } from '@/site/paths';
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
import { AI_TEXT_GENERATION_ENABLED } from '@/site/config';
import { AI_TEXT_GENERATION_ENABLED, BLUR_ENABLED } from '@/site/config';
import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server';
import { getNextImageUrlForManipulation } from '@/services/next-image';
export default async function PhotoEditPage({
params: { photoId },
@ -16,12 +18,25 @@ export default async function PhotoEditPage({
const uniqueTags = await getUniqueTagsCached();
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
// Only generate image thumbnails when AI generation is enabled
const imageThumbnailBase64 = AI_TEXT_GENERATION_ENABLED
? await resizeImageFromUrl(getNextImageUrlForManipulation(photo.url))
: '';
const blurData = BLUR_ENABLED
? await blurImageFromUrl(
getNextImageUrlForManipulation(photo.url)
)
: '';
return (
<PhotoEditPageClient {...{
photo,
uniqueTags,
hasAiTextGeneration,
imageThumbnailBase64,
blurData,
}} />
);
};

View File

@ -2,14 +2,14 @@ import PhotoUpload from '@/photo/PhotoUpload';
import { clsx } from 'clsx/lite';
import SiteGrid from '@/components/SiteGrid';
import { getPhotosCountIncludingHiddenCached } from '@/photo/cache';
import StorageUrls from '@/admin/StorageUrls';
import AdminUploadsTable from '@/admin/AdminUploadsTable';
import { PRO_MODE_ENABLED } from '@/site/config';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import { getPhotos } from '@/photo/db';
import { revalidatePath } from 'next/cache';
import AdminPhotoTable from '@/admin/AdminPhotoTable';
import AdminPhotoTableInfinite from
'@/admin/AdminPhotoTableInfinite';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from
'@/admin/AdminPhotosTableInfinite';
const DEBUG_PHOTO_BLOBS = false;
@ -50,15 +50,15 @@ export default async function AdminPhotosPage() {
'border-b pb-6',
'border-gray-200 dark:border-gray-700',
)}>
<StorageUrls
<AdminUploadsTable
title={`Photo Blobs (${blobPhotoUrls.length})`}
urls={blobPhotoUrls}
/>
</div>}
<div className="space-y-4">
<AdminPhotoTable photos={photos} />
<AdminPhotosTable photos={photos} />
{photosCount > photos.length &&
<AdminPhotoTableInfinite
<AdminPhotosTableInfinite
initialOffset={INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS}
/>}

View File

@ -1,11 +1,12 @@
import { PATH_ADMIN } from '@/site/paths';
import { extractExifDataFromBlobPath } from '@/photo/server';
import { extractImageDataFromBlobPath } from '@/photo/server';
import { redirect } from 'next/navigation';
import { getUniqueTagsCached } from '@/photo/cache';
import UploadPageClient from '@/photo/UploadPageClient';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
BLUR_ENABLED,
} from '@/site/config';
interface Params {
@ -16,9 +17,19 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
const {
blobId,
photoFormExif,
} = await extractExifDataFromBlobPath(uploadPath, true);
imageResizedBase64: imageThumbnailBase64,
} = await extractImageDataFromBlobPath(uploadPath, {
includeInitialPhotoFields: true,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
});
if (!photoFormExif) { redirect(PATH_ADMIN); }
if (
!photoFormExif ||
(AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64)
) {
redirect(PATH_ADMIN);
}
const uniqueTags = await getUniqueTagsCached();
@ -33,6 +44,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
imageThumbnailBase64,
}} />
);
};

View File

@ -1,4 +1,4 @@
import StorageUrls from '@/admin/StorageUrls';
import AdminUploadsTable from '@/admin/AdminUploadsTable';
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import SiteGrid from '@/components/SiteGrid';
@ -6,7 +6,7 @@ export default async function AdminUploadsPage() {
const storageUrls = await getStorageUploadUrlsNoStore();
return (
<SiteGrid
contentMain={<StorageUrls urls={storageUrls} />}
contentMain={<AdminUploadsTable urls={storageUrls} />}
/>
);
}

View File

@ -1,126 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
const RETRY_DELAY = 2000;
export default function CanvasBlurCapture({
imageUrl,
onLoad,
onCapture,
onError,
width,
height,
hidden = true,
edgeCompensation = 10,
scale = 0.5,
quality = 0.9,
}: {
imageUrl: string
onLoad?: (imageData: string) => void
onCapture: (imageData: string) => void
onError?: (error: string) => void
width: number
height: number
hidden?: boolean
edgeCompensation?: number
scale?: number
quality?: number
}) {
const refCanvas = useRef<HTMLCanvasElement | null>(null);
const refImage = useRef(typeof Image !== 'undefined' ? new Image() : null);
const refTimeouts = useRef<NodeJS.Timeout[]>([]);
const refShouldCapture = useRef(true);
useEffect(() => {
refShouldCapture.current = true;
const capture = () => {
if (refShouldCapture.current) {
if (
refCanvas.current &&
refImage.current?.complete
) {
const canvas = refCanvas.current;
canvas.width = width * scale;
canvas.height = height * scale;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const context = refCanvas.current?.getContext('2d');
if (context) {
// Draw scaled image
context.scale(scale, scale);
context.drawImage(
refImage.current,
-edgeCompensation,
-edgeCompensation,
width + edgeCompensation * 2,
width * refImage.current.height / refImage.current.width +
edgeCompensation * 2,
);
onLoad?.(canvas.toDataURL('image/jpeg', quality));
// Draw blurred image
context.filter =
'contrast(1.2) saturate(1.2) ' +
`blur(${scale * 10}px)`;
context.drawImage(
refImage.current,
-edgeCompensation,
-edgeCompensation,
width + edgeCompensation * 2,
width * refImage.current.height / refImage.current.width +
edgeCompensation * 2,
);
onCapture(canvas.toDataURL('image/jpeg', quality));
onError?.('');
refTimeouts.current.forEach(clearTimeout);
refShouldCapture.current = false;
} else {
console.error('Cannot get 2d context ... retrying');
onError?.('Cannot get 2d context ... retrying');
// Retry capture in case canvas is not available
refTimeouts.current.push(setTimeout(capture, RETRY_DELAY));
}
} else {
// eslint-disable-next-line max-len
console.error('Cannot generate blur data: canvas/image not ready ... retrying');
// eslint-disable-next-line max-len
onError?.('Cannot generate blur data: canvas/image not ready ... retrying');
// Retry capture in case canvas is not available
refTimeouts.current.push(setTimeout(capture, RETRY_DELAY));
}
}
};
if (refImage.current) {
refImage.current.crossOrigin = 'anonymous';
refImage.current.src = imageUrl;
refImage.current.onload = capture;
}
// Attempt delayed capture in case image.onload never fires
refTimeouts.current.push(setTimeout(capture, RETRY_DELAY));
// Store timeout ref to ensure it's closed over
// in cleanup function (recommended by exhaustive-deps)
const timeouts = refTimeouts.current;
return () => {
refShouldCapture.current = false;
timeouts.forEach(clearTimeout);
};
}, [
imageUrl,
onCapture,
onLoad,
onError,
width,
height,
edgeCompensation,
scale,
quality,
]);
return (
<canvas ref={refCanvas} className={hidden ? 'hidden' : undefined} />
);
}

View File

@ -36,6 +36,7 @@ import { TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
import PhotoTiny from '@/photo/PhotoTiny';
import { FaCheck } from 'react-icons/fa6';
const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
@ -69,9 +70,12 @@ export default function CommandKClient({
isUserSignedIn,
setUserEmail,
isCommandKOpen: isOpen,
shouldShowBaselineGrid,
shouldDebugBlur,
setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands,
setShouldShowBaselineGrid,
setShouldDebugBlur,
} = useAppState();
const isOpenRef = useRef(isOpen);
@ -193,8 +197,13 @@ export default function CommandKClient({
heading: 'Debug Tools',
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
items: [{
label: 'Toggle Blur Debug',
action: () => setShouldDebugBlur?.(prev => !prev),
annotation: shouldDebugBlur ? <FaCheck size={12} /> : undefined,
}, {
label: 'Toggle Baseline Grid',
action: () => setShouldShowBaselineGrid?.(prev => !prev),
annotation: shouldShowBaselineGrid ? <FaCheck size={12} /> : undefined,
}],
});
}

View File

@ -1,52 +0,0 @@
'use client';
import { clsx } from 'clsx/lite';
import Spinner, { SpinnerColor } from './Spinner';
export default function IconButton({
icon,
onClick,
isLoading,
className,
spinnerColor,
spinnerSize,
}: {
icon: JSX.Element
onClick?: () => void
isLoading?: boolean
className?: string
spinnerColor?: SpinnerColor
spinnerSize?: number
}) {
return (
<span className={clsx(
className,
'relative inline-flex items-center',
'w-[1rem] h-[1.1rem]',
)}>
{!isLoading
? <button
onClick={onClick}
className={clsx(
'inline-flex items-center justify-center',
'p-0 border-none shadow-none',
'active:bg-transparent bg-transparent dark:bg-transparent',
'translate-x-[-1px]',
onClick !== undefined && 'cursor-pointer',
'active:opacity-50',
)}
>
{icon}
</button>
: <span className={clsx(
'inline-flex items-center justify-center',
'h-full w-full',
)}>
<Spinner
color={spinnerColor}
size={spinnerSize}
/>
</span>}
</span>
);
}

View File

@ -2,18 +2,24 @@
/* eslint-disable jsx-a11y/alt-text */
import { BLUR_ENABLED } from '@/site/config';
import { useAppState } from '@/state/AppState';
import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
export default function ImageBlurFallback(props: ImageProps) {
export default function ImageBlurFallback(props: ImageProps & {
blurCompatibilityLevel?: 'none' | 'low' | 'high';
}) {
const {
className,
priority,
blurDataURL,
blurCompatibilityLevel = 'low',
...rest
} = props;
const { shouldDebugBlur } = useAppState();
const [wasCached, setWasCached] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [didError, setDidError] = useState(false);
@ -48,6 +54,16 @@ export default function ImageBlurFallback(props: ImageProps) {
!wasCached &&
!hideBlurPlaceholder;
const getBlurClass = () => {
switch (blurCompatibilityLevel) {
case 'high':
// Fix poorly blurred placeholder data generated on client
return 'blur-[4px] @xs:blue-md scale-[1.05]';
case 'low':
return 'blur-[2px] @xs:blue-md scale-[1.01]';
}
};
return (
<div
className={clsx(
@ -55,13 +71,13 @@ export default function ImageBlurFallback(props: ImageProps) {
'flex relative',
)}
>
{showPlaceholder &&
{(showPlaceholder || shouldDebugBlur) &&
<div className={clsx(
'@container',
'absolute inset-0',
'bg-main overflow-hidden',
'transition-opacity duration-300 ease-in',
isLoading ? 'opacity-100' : 'opacity-0',
(isLoading || shouldDebugBlur) ? 'opacity-100' : 'opacity-0',
)}>
{(BLUR_ENABLED && props.blurDataURL)
? <img {...{
@ -69,8 +85,7 @@ export default function ImageBlurFallback(props: ImageProps) {
src: blurDataURL,
className: clsx(
imageClassName,
// Fix poorly blurred placeholder data generated by Safari
'blur-sm @xs:blue-md scale-105',
getBlurClass(),
),
}} />
: <div className={clsx(

View File

@ -7,6 +7,7 @@ export default function ImageLarge({
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
priority,
}: {
className?: string
@ -14,6 +15,7 @@ export default function ImageLarge({
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
priority?: boolean
}) {
return (
@ -21,8 +23,9 @@ export default function ImageLarge({
className,
src,
alt,
priority,
blurDataURL: blurData,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
priority,
width: IMAGE_LARGE_WIDTH,
height: Math.round(IMAGE_LARGE_WIDTH / aspectRatio),
}} />

View File

@ -7,6 +7,7 @@ export default function ImageSmall({
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
priority,
}: {
className?: string
@ -14,6 +15,7 @@ export default function ImageSmall({
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
priority?: boolean
}) {
return (
@ -21,8 +23,9 @@ export default function ImageSmall({
className,
src,
alt,
priority,
blurDataURL: blurData,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
priority,
width: IMAGE_SMALL_WIDTH,
height: Math.round(IMAGE_SMALL_WIDTH / aspectRatio),
}} />

View File

@ -7,12 +7,14 @@ export default function ImageTiny({
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
}: {
className?: string
src: string
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
}) {
return (
<ImageBlurFallback {...{
@ -20,6 +22,7 @@ export default function ImageTiny({
src,
alt,
blurDataURL: blurData,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
width: IMAGE_TINY_WIDTH,
height: Math.round(IMAGE_TINY_WIDTH / aspectRatio),
}} />

View File

@ -1,4 +1,4 @@
import clsx from 'clsx/lite';
import { clsx } from 'clsx/lite';
import Spinner from './Spinner';
import SiteGrid from './SiteGrid';

View File

@ -1,27 +1,33 @@
import { TbPhotoShare } from 'react-icons/tb';
import IconPathButton from '@/components/IconPathButton';
import PathLoaderButton from './primitives/PathLoaderButton';
import clsx from 'clsx';
export default function ShareButton({
path,
prefetch,
shouldScroll,
dim,
className,
}: {
path: string
prefetch?: boolean
shouldScroll?: boolean
dim?: boolean
className?: string
}) {
return (
<IconPathButton {...{
path,
icon: <TbPhotoShare size={17} className={dim
? 'text-dim'
: undefined} />,
prefetch,
shouldScroll,
shouldReplace: true,
spinnerColor: 'dim',
}} />
<PathLoaderButton
path={path}
className={clsx(
className,
dim ? 'text-dim' : 'text-medium',
)}
icon={<TbPhotoShare size={17} />}
spinnerColor="dim"
prefetch={prefetch}
shouldScroll={shouldScroll}
shouldReplace
styleAsLink
/>
);
}

View File

@ -2,9 +2,10 @@
import { HTMLProps, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import Spinner, { SpinnerColor } from './Spinner';
import { SpinnerColor } from './Spinner';
import { clsx } from 'clsx/lite';
import { toastSuccess } from '@/toast';
import LoaderButton from '@/components/primitives/LoaderButton';
interface Props extends HTMLProps<HTMLButtonElement> {
icon?: JSX.Element
@ -49,34 +50,21 @@ export default function SubmitButtonWithStatus({
}, [onFormStatusChange, pending]);
return (
<button
<LoaderButton
type="submit"
disabled={disabled}
className={clsx(
className,
'inline-flex items-center gap-2',
primary && 'primary',
styleAsLink && 'link',
)}
icon={icon}
spinnerColor={spinnerColor}
styleAsLink={styleAsLink}
isLoading={pending}
{...buttonProps}
>
{(icon || pending) &&
<span className={clsx(
'h-4',
'min-w-[1rem]',
'inline-flex justify-center sm:justify-normal',
'-mx-0.5',
'translate-y-[1px]',
)}>
{pending
? <Spinner size={14} color={spinnerColor} />
: icon}
</span>}
{children && <span className={clsx(
icon !== undefined && 'hidden sm:inline-block',
)}>
{children}
</span>}
</button>
{children}
</LoaderButton>
);
};

View File

@ -0,0 +1,56 @@
import Spinner, { SpinnerColor } from '@/components/Spinner';
import { clsx } from 'clsx/lite';
import { ButtonHTMLAttributes, ReactNode } from 'react';
export default function LoaderButton(props: {
children?: ReactNode
isLoading?: boolean
icon?: JSX.Element
spinnerColor?: SpinnerColor
styleAsLink?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const {
children,
isLoading,
icon,
spinnerColor,
styleAsLink,
type = 'button',
disabled,
className,
...rest
} = props;
return (
<button
{...rest}
type={type}
className={clsx(
className,
styleAsLink
? 'link h-4 hover:text-dim active:text-medium'
: 'h-9',
'inline-flex items-center gap-2 self-start',
)}
disabled={isLoading || disabled}
>
{(icon || isLoading) &&
<span className={clsx(
'min-w-[1.25rem] h-4 translate-y-[-0.5px]',
'inline-flex justify-center',
)}>
{isLoading
? <Spinner
size={14}
color={spinnerColor}
className="translate-y-[2px]"
/>
: icon}
</span>}
{children && <span className={clsx(
icon !== undefined && 'hidden sm:inline-block',
)}>
{children}
</span>}
</button>
);
}

View File

@ -1,27 +1,32 @@
'use client';
import { useRouter } from 'next/navigation';
import IconButton from './IconButton';
import { useEffect, useState, useTransition } from 'react';
import { clsx } from 'clsx/lite';
import { SpinnerColor } from './Spinner';
import { ReactNode, useEffect, useState, useTransition } from 'react';
import { SpinnerColor } from '../Spinner';
import LoaderButton from '@/components/primitives/LoaderButton';
export default function IconPathButton({
icon,
export default function PathLoaderButton({
path,
icon,
prefetch,
loaderDelay = 250,
loaderDelay = 100,
shouldScroll = true,
shouldReplace,
spinnerColor,
styleAsLink,
className,
children,
}: {
icon: JSX.Element
path: string
icon?: JSX.Element
prefetch?: boolean
loaderDelay?: number
shouldScroll?: boolean
shouldReplace?: boolean
spinnerColor?: SpinnerColor
styleAsLink?: boolean
className?: string
children?: ReactNode
}) {
const router = useRouter();
@ -47,8 +52,9 @@ export default function IconPathButton({
}, [prefetch, router, path]);
return (
<IconButton
<LoaderButton
icon={icon}
className={className}
onClick={() => startTransition(() => {
if (shouldReplace) {
router.replace(path, { scroll: shouldScroll });
@ -57,13 +63,10 @@ export default function IconPathButton({
}
})}
isLoading={shouldShowLoader}
className={clsx(
'translate-y-[-0.5px]',
'active:translate-y-[1px]',
'text-medium',
'active:text-gray-600 dark:active:text-gray-300',
)}
spinnerColor={spinnerColor ?? 'text'}
/>
spinnerColor={spinnerColor}
styleAsLink={styleAsLink}
>
{children}
</LoaderButton>
);
}

View File

@ -21,10 +21,14 @@ export default function PhotoEditPageClient({
photo,
uniqueTags,
hasAiTextGeneration,
imageThumbnailBase64,
blurData,
}: {
photo: Photo
uniqueTags: TagsWithMeta
hasAiTextGeneration: boolean
imageThumbnailBase64: string
blurData: string
}) {
const seedExifData = { url: photo.url };
@ -48,7 +52,10 @@ export default function PhotoEditPageClient({
hasTextContent,
setHasTextContent,
aiContent,
} = usePhotoFormParent({ photoForm });
} = usePhotoFormParent({
photoForm,
imageThumbnailBase64,
});
return (
<AdminChildPage
@ -81,6 +88,7 @@ export default function PhotoEditPageClient({
updatedExifData={hasExifDataBeenFound
? updatedExifData
: undefined}
updatedBlurData={blurData}
uniqueTags={uniqueTags}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}

View File

@ -3,6 +3,7 @@
import {
Photo,
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
shouldShowCameraDataForPhoto,
shouldShowExifDataForPhoto,
} from '.';
@ -81,6 +82,7 @@ export default function PhotoLarge({
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
/>
</Link>}
@ -168,6 +170,10 @@ export default function PhotoLarge({
{photo.takenAtNaiveFormatted}
</div>
<ShareButton
className={clsx(
'md:translate-x-[-2.5px]',
'translate-y-[1.5px] md:translate-y-0',
)}
path={pathForPhotoShare(
photo,
shouldShareTag ? primaryTag : undefined,

View File

@ -62,9 +62,11 @@ export default function PhotoSetHeader({
? `${entityVerb} ${selectedPhotoIndex + 1} of ${count ?? photos.length}`
: entityDescription}
{selectedPhotoIndex === undefined &&
<span className="translate-y-[1px]">
<ShareButton path={sharePath} dim />
</span>}
<ShareButton
className="translate-y-[1.5px]"
path={sharePath}
dim
/>}
</span>
<span className={clsx(
'hidden sm:inline-block',

View File

@ -1,6 +1,6 @@
'use client';
import { Photo, altTextForPhoto } from '.';
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.';
import ImageSmall from '@/components/ImageSmall';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
@ -49,6 +49,7 @@ export default function PhotoSmall({
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
className="w-full"
alt={altTextForPhoto(photo)}
priority={priority}

View File

@ -1,4 +1,4 @@
import { Photo, altTextForPhoto } from '.';
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.';
import ImageTiny from '@/components/ImageTiny';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
@ -44,6 +44,7 @@ export default function PhotoTiny({
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
alt={altTextForPhoto(photo)}
/>
</Link>

View File

@ -0,0 +1,36 @@
import { clsx } from 'clsx/lite';
import { FiRotateCcw } from 'react-icons/fi';
import { getImageBlurAction } from './actions';
import { useState } from 'react';
import Spinner from '@/components/Spinner';
export default function UpdateBlurDataButton({
photoUrl,
onUpdatedBlurData,
}: {
photoUrl?: string
onUpdatedBlurData: (blurData: string) => void
}) {
const [isLoading, setIsLoading] = useState(false);
return (
<button
type="button"
className={clsx(
'flex min-w-[3.25rem] min-h-9 justify-center',
'h-full',
)}
disabled={!photoUrl || isLoading}
onClick={() => {
if (photoUrl) {
setIsLoading(true);
getImageBlurAction(photoUrl)
.then(blurData => onUpdatedBlurData(blurData))
.finally(() => setIsLoading(false));
}
}}
>
{isLoading ? <Spinner /> : <FiRotateCcw size={18} />}
</button>
);
}

View File

@ -16,12 +16,14 @@ export default function UploadPageClient({
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
imageThumbnailBase64,
}: {
blobId?: string
photoFormExif: Partial<PhotoFormData>
uniqueTags: TagsWithMeta
hasAiTextGeneration?: boolean
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
imageThumbnailBase64?: string
}) {
const {
pending,
@ -31,7 +33,10 @@ export default function UploadPageClient({
hasTextContent,
setHasTextContent,
aiContent,
} = usePhotoFormParent({ textFieldsToAutoGenerate });
} = usePhotoFormParent({
textFieldsToAutoGenerate,
imageThumbnailBase64,
});
const initialPhotoForm = useMemo(() => ({
...photoFormExif,

View File

@ -33,15 +33,18 @@ import {
PATH_ROOT,
pathForPhoto,
} from '@/site/paths';
import { extractExifDataFromBlobPath } from './server';
import { blurImageFromUrl, extractImageDataFromBlobPath } from './server';
import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert } from '.';
import { safelyRunAdminServerAction } from '@/auth';
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/services/openai';
import { BLUR_ENABLED } from '@/site/config';
export async function createPhotoAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
// Private actions
export const createPhotoAction = async (formData: FormData) =>
safelyRunAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData, true);
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
@ -54,10 +57,9 @@ export async function createPhotoAction(formData: FormData) {
redirect(PATH_ADMIN_PHOTOS);
});
}
export async function updatePhotoAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
export const updatePhotoAction = async (formData: FormData) =>
safelyRunAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData);
await sqlUpdatePhoto(photo);
@ -66,13 +68,12 @@ export async function updatePhotoAction(formData: FormData) {
redirect(PATH_ADMIN_PHOTOS);
});
}
export async function toggleFavoritePhotoAction(
export const toggleFavoritePhotoAction = async (
photoId: string,
shouldRedirect?: boolean,
) {
return safelyRunAdminServerAction(async () => {
) =>
safelyRunAdminServerAction(async () => {
const photo = await getPhoto(photoId);
if (photo) {
const { tags } = photo;
@ -86,33 +87,30 @@ export async function toggleFavoritePhotoAction(
}
}
});
}
export async function deletePhotoAction(
export const deletePhotoAction = async (
photoId: string,
photoUrl: string,
shouldRedirect?: boolean,
) {
return safelyRunAdminServerAction(async () => {
) =>
safelyRunAdminServerAction(async () => {
await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
redirect(PATH_ROOT);
}
});
};
export async function deletePhotoFormAction(formData: FormData) {
return safelyRunAdminServerAction(async () =>
export const deletePhotoFormAction = async (formData: FormData) =>
safelyRunAdminServerAction(() =>
deletePhotoAction(
formData.get('id') as string,
formData.get('url') as string,
)
);
};
export async function deletePhotoTagGloballyAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
export const deletePhotoTagGloballyAction = async (formData: FormData) =>
safelyRunAdminServerAction(async () => {
const tag = formData.get('tag') as string;
await sqlDeletePhotoTagGlobally(tag);
@ -120,10 +118,9 @@ export async function deletePhotoTagGloballyAction(formData: FormData) {
revalidatePhotosKey();
revalidateAdminPaths();
});
}
export async function renamePhotoTagGloballyAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
export const renamePhotoTagGloballyAction = async (formData: FormData) =>
safelyRunAdminServerAction(async () => {
const tag = formData.get('tag') as string;
const updatedTag = formData.get('updatedTag') as string;
@ -134,10 +131,9 @@ export async function renamePhotoTagGloballyAction(formData: FormData) {
redirect(PATH_ADMIN_TAGS);
}
});
}
export async function deleteBlobPhotoAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
export const deleteBlobPhotoAction = async (formData: FormData) =>
safelyRunAdminServerAction(async () => {
await deleteStorageUrl(formData.get('url') as string);
revalidateAdminPaths();
@ -146,30 +142,35 @@ export async function deleteBlobPhotoAction(formData: FormData) {
redirect(PATH_ADMIN_PHOTOS);
}
});
}
export async function getExifDataAction(
// Accessed from admin photo edit page
// will not update blur data
export const getExifDataAction = async (
photoFormPrevious: Partial<PhotoFormData>,
): Promise<Partial<PhotoFormData>> {
return safelyRunAdminServerAction(async () => {
): Promise<Partial<PhotoFormData>> =>
safelyRunAdminServerAction(async () => {
const { url } = photoFormPrevious;
if (url) {
const { photoFormExif } = await extractExifDataFromBlobPath(url);
const { photoFormExif } = await extractImageDataFromBlobPath(url);
if (photoFormExif) {
return photoFormExif;
}
}
return {};
});
}
export async function syncPhotoExifDataAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
// Accessed from admin photo table
// will update blur data
export const syncPhotoExifDataAction = async (formData: FormData) =>
safelyRunAdminServerAction(async () => {
const photoId = formData.get('id') as string;
if (photoId) {
const photo = await getPhoto(photoId);
if (photo) {
const { photoFormExif } = await extractExifDataFromBlobPath(photo.url);
const { photoFormExif } = await extractImageDataFromBlobPath(
photo.url, {
generateBlurData: BLUR_ENABLED,
});
if (photoFormExif) {
const photoFormDbInsert = convertFormDataToPhotoDbInsert({
...convertPhotoToFormData(photo),
@ -181,26 +182,21 @@ export async function syncPhotoExifDataAction(formData: FormData) {
}
}
});
}
export async function syncCacheAction() {
return safelyRunAdminServerAction(revalidateAllKeysAndPaths);
}
export const syncCacheAction = async () =>
safelyRunAdminServerAction(revalidateAllKeysAndPaths);
export async function streamAiImageQueryAction(
export const streamAiImageQueryAction = async (
imageBase64: string,
query: AiImageQuery,
) {
return safelyRunAdminServerAction(async () =>
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
}
export const getPhotosCachedAction = async (
offset: number,
limit: number,
includeHidden?: boolean,
) =>
getPhotosCachedCached({ offset, includeHidden, limit });
safelyRunAdminServerAction(() =>
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
export const getImageBlurAction = async (url: string) =>
safelyRunAdminServerAction(() => blurImageFromUrl(url));
// Public actions
export const getPhotosAction = async (
offset: number,
@ -209,6 +205,13 @@ export const getPhotosAction = async (
) =>
getPhotos({ offset, includeHidden, limit });
export const getPhotosCachedAction = async (
offset: number,
limit: number,
includeHidden?: boolean,
) =>
getPhotosCachedCached({ offset, includeHidden, limit });
export const queryPhotosByTitleAction = async (query: string) =>
(await getPhotos({ query, limit: 10 }))
.filter(({ title }) => Boolean(title));

View File

@ -56,7 +56,7 @@ export default function AiButton({
e.preventDefault();
}
}}
disabled={!aiContent.isReady || isLoading}
disabled={isLoading}
>
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
</button>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import useAiImageQuery from './useAiImageQuery';
import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery';
import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.';
@ -7,11 +7,8 @@ export type AiContent = ReturnType<typeof useAiImageQueries>;
export default function useAiImageQueries(
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
imageData?: string,
) {
const [imageData, setImageData] = useState<string>();
const isReady = Boolean(imageData);
const [
requestTitleCaption,
_title,
@ -115,12 +112,10 @@ export default function useAiImageQueries(
caption,
tags,
semanticDescription,
isReady,
isLoading,
isLoadingTitle,
isLoadingCaption,
isLoadingTags,
isLoadingSemantic,
setImageData,
};
}

View File

@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
FORM_METADATA_ENTRIES,
PhotoFormData,
@ -15,42 +15,41 @@ import { createPhotoAction, updatePhotoAction } from '../actions';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config';
import { TagsWithMeta, sortTagsObjectWithoutFavs } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import { AiContent } from '../ai/useAiImageQueries';
import AiButton from '../ai/AiButton';
import Spinner from '@/components/Spinner';
import { getNextImageUrlForRequest } from '@/services/next-image';
import useDelay from '@/utility/useDelay';
import usePreventNavigation from '@/utility/usePreventNavigation';
import { useAppState } from '@/state/AppState';
import UpdateBlurDataButton from '../UpdateBlurDataButton';
import { getNextImageUrlForManipulation } from '@/services/next-image';
import { BLUR_ENABLED } from '@/site/config';
import { PhotoDbInsert } from '..';
const THUMBNAIL_SIZE = 300;
export default function PhotoForm({
type = 'create',
initialPhotoForm,
updatedExifData,
type = 'create',
updatedBlurData,
uniqueTags,
aiContent,
debugBlur,
onTitleChange,
onTextContentChange,
onFormStatusChange,
}: {
type?: 'create' | 'edit'
initialPhotoForm: Partial<PhotoFormData>
updatedExifData?: Partial<PhotoFormData>
type?: 'create' | 'edit'
updatedBlurData?: string
uniqueTags?: TagsWithMeta
aiContent?: AiContent
setImageData?: (imageData: string) => void
debugBlur?: boolean
onTitleChange?: (updatedTitle: string) => void
onTextContentChange?: (hasContent: boolean) => void,
onFormStatusChange?: (pending: boolean) => void
@ -59,11 +58,8 @@ export default function PhotoForm({
useState<Partial<PhotoFormData>>(initialPhotoForm);
const [formErrors, setFormErrors] =
useState(getFormErrors(initialPhotoForm));
const [blurError, setBlurError] =
useState<string>();
const [hasBlurData, setHasBlurData] = useState(false);
const { invalidateSwr } = useAppState();
const { invalidateSwr, shouldDebugBlur } = useAppState();
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
@ -79,17 +75,6 @@ export default function PhotoForm({
(type === 'create' || formHasChanged) &&
isFormValid(formData) &&
!aiContent?.isLoading;
const didLoad1000msAgo = useDelay(1000);
// Show image loading status when necessary for
// blur data or AI analysis
const showImageLoadingStatus =
!hasBlurData &&
didLoad1000msAgo && (
(BLUR_ENABLED && !formData.blurData) ||
aiContent !== undefined
);
// Update form when EXIF data
// is refreshed by parent
@ -130,15 +115,15 @@ export default function PhotoForm({
const url = formData.url ?? '';
const updateBlurData = useCallback((blurData: string) => {
if (BLUR_ENABLED) {
setFormData(data => ({
...data,
blurData,
}));
useEffect(() => {
if (updatedBlurData) {
setFormData(data => updatedBlurData
? { ...data, blurData: updatedBlurData }
: data);
} else if (!BLUR_ENABLED) {
setFormData(data => ({ ...data, blurData: '' }));
}
setHasBlurData(true);
}, []);
}, [updatedBlurData]);
useEffect(() =>
setFormData(data => aiContent?.title
@ -183,7 +168,7 @@ export default function PhotoForm({
}
};
const aiButtonForField = (key: keyof PhotoFormData) => {
const accessoryForField = (key: keyof PhotoFormData) => {
if (aiContent) {
switch (key) {
case 'title':
@ -213,16 +198,35 @@ export default function PhotoForm({
requestFields={['semantic']}
shouldConfirm={Boolean(formData.semanticDescription)}
/>;
case 'blurData':
return shouldDebugBlur && type === 'edit' && formData.url
? <UpdateBlurDataButton
photoUrl={getNextImageUrlForManipulation(formData.url)}
onUpdatedBlurData={blurData =>
setFormData(data => ({ ...data, blurData }))}
/>
: null;
}
}
};
const shouldHideField = (
key: keyof PhotoDbInsert | 'favorite',
hideIfEmpty?: boolean,
shouldHide?: (formData: Partial<PhotoFormData>) => boolean,
) => {
if (key === 'blurData' && type === 'create' && !shouldDebugBlur) {
return true;
} else {
return (
(hideIfEmpty && !formData[key]) ||
shouldHide?.(formData)
);
}
};
return (
<div className="space-y-8 max-w-[38rem] relative">
{debugBlur && blurError &&
<div className="border error text-error rounded-md px-2 py-1">
{blurError}
</div>}
<div className="flex gap-2">
<div className="relative">
<ImageBlurFallback
@ -232,13 +236,15 @@ export default function PhotoForm({
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700',
)}
blurDataURL={formData.blurData}
blurCompatibilityLevel="none"
width={width}
height={height}
priority
/>
<div className={clsx(
'absolute top-2 left-2 transition-opacity duration-500',
showImageLoadingStatus ? 'opacity-100' : 'opacity-0',
aiContent?.isLoading ? 'opacity-100' : 'opacity-0',
)}>
<div className={clsx(
'leading-none text-xs font-medium uppercase tracking-wide',
@ -246,43 +252,20 @@ export default function PhotoForm({
'inline-flex items-center gap-2',
'bg-white/70 dark:bg-black/60 backdrop-blur-md',
'border border-gray-900/10 dark:border-gray-700/70',
'select-none',
)}>
<Spinner
color="text"
size={9}
className={clsx(
'text-extra-dim',
'translate-x-[1px] translate-y-[0.5px]'
'translate-x-[1px] translate-y-[0.5px]',
)}
/>
Analyzing image
</div>
</div>
</div>
<CanvasBlurCapture
imageUrl={getNextImageUrlForRequest(
url,
640,
undefined,
window.location.origin,
)}
width={width}
height={height}
onLoad={aiContent?.setImageData}
onCapture={updateBlurData}
onError={setBlurError}
/>
{debugBlur && formData.blurData &&
<img
alt="blur"
src={formData.blurData}
className={clsx(
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700'
)}
width={width}
height={height}
/>}
</div>
<form
action={type === 'create' ? createPhotoAction : updatePhotoAction}
@ -315,14 +298,13 @@ export default function PhotoForm({
loadingMessage,
type,
}]) =>
(
(!hideIfEmpty || formData[key]) &&
!shouldHide?.(formData)
) &&
!shouldHideField(key, hideIfEmpty, shouldHide) &&
<FieldSetWithStatus
key={key}
id={key}
label={label}
label={label + (key === 'blurData' && shouldDebugBlur
? ` (${(formData[key] ?? '').length} chars.)`
: '')}
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}
@ -357,7 +339,7 @@ export default function PhotoForm({
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)}
type={type}
accessory={aiButtonForField(key)}
accessory={accessoryForField(key)}
/>)}
</div>
{/* Actions */}

View File

@ -15,10 +15,7 @@ import {
MAKE_FUJIFILM,
} from '@/vendors/fujifilm';
import { FilmSimulation } from '@/simulation';
import {
BLUR_ENABLED,
GEO_PRIVACY_ENABLED,
} from '@/site/config';
import { GEO_PRIVACY_ENABLED } from '@/site/config';
import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag';
type VirtualFields = 'favorite';
@ -42,7 +39,7 @@ type FormMeta = {
label: string
note?: string
required?: boolean
virtual?: boolean
excludeFromInsert?: boolean
readOnly?: boolean
validate?: (value?: string) => string | undefined
validateStringMaxLength?: number
@ -94,9 +91,6 @@ const FORM_METADATA = (
blurData: {
label: 'blur data',
readOnly: true,
required: BLUR_ENABLED,
hideIfEmpty: !BLUR_ENABLED,
loadingMessage: 'Generating blur data ...',
},
url: { label: 'url', readOnly: true },
extension: { label: 'extension', readOnly: true },
@ -121,7 +115,7 @@ const FORM_METADATA = (
takenAt: { label: 'taken at' },
takenAtNaive: { label: 'taken at (naive)' },
priorityOrder: { label: 'priority order' },
favorite: { label: 'favorite', type: 'checkbox', virtual: true },
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
hidden: { label: 'hidden', type: 'checkbox' },
});
@ -242,10 +236,11 @@ export const convertFormDataToPhotoDbInsert = (
// - remove server action ID
// - remove empty strings
Object.keys(photoForm).forEach(key => {
const meta = FORM_METADATA()[key as keyof PhotoFormData];
if (
key.startsWith('$ACTION_ID_') ||
(photoForm as any)[key] === '' ||
FORM_METADATA()[key as keyof PhotoFormData]?.virtual
meta?.excludeFromInsert
) {
delete (photoForm as any)[key];
}

View File

@ -6,16 +6,21 @@ import { AiAutoGeneratedField } from '../ai';
export default function usePhotoFormParent({
photoForm,
textFieldsToAutoGenerate,
imageThumbnailBase64,
}: {
photoForm?: Partial<PhotoFormData>,
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
} = {}) {
photoForm?: Partial<PhotoFormData>
textFieldsToAutoGenerate?: AiAutoGeneratedField[]
imageThumbnailBase64?: string,
}) {
const [pending, setIsPending] = useState(false);
const [updatedTitle, setUpdatedTitle] = useState('');
const [hasTextContent, setHasTextContent] =
useState(photoForm ? formHasTextContent(photoForm) : false);
const aiContent = useAiImageQueries(textFieldsToAutoGenerate);
const aiContent = useAiImageQueries(
textFieldsToAutoGenerate,
imageThumbnailBase64,
);
return {
pending,

View File

@ -11,6 +11,7 @@ import {
formatFocalLength,
} from '@/utility/exif';
import camelcaseKeys from 'camelcase-keys';
import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
@ -278,3 +279,6 @@ export const isNextImageReadyBasedOnPhotos = async (photos: Photo[]) =>
photos.length > 0 && fetch(getNextImageUrlForRequest(photos[0].url, 640))
.then(response => response.ok)
.catch(() => false);
export const doesPhotoNeedBlurCompatibility = (photo: Photo) =>
isBefore(photo.updatedAt, new Date('2024-05-07'));

View File

@ -10,14 +10,29 @@ import {
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
import { PhotoFormData } from './form';
import { FilmSimulation } from '@/simulation';
import sharp, { Sharp } from 'sharp';
export const extractExifDataFromBlobPath = async (
const IMAGE_WIDTH_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200;
export const extractImageDataFromBlobPath = async (
blobPath: string,
includeInitialPhotoFields?: boolean,
options?: {
includeInitialPhotoFields?: boolean
generateBlurData?: boolean
generateResizedImage?: boolean
},
): Promise<{
blobId?: string
photoFormExif?: Partial<PhotoFormData>
imageResizedBase64?: string
}> => {
const {
includeInitialPhotoFields,
generateBlurData,
generateResizedImage,
} = options ?? {};
const url = decodeURIComponent(blobPath);
const blobId = getIdFromStorageUrl(url);
@ -25,12 +40,13 @@ export const extractExifDataFromBlobPath = async (
const extension = getExtensionFromStorageUrl(url);
const fileBytes = blobPath
? await fetch(url)
.then(res => res.arrayBuffer())
? await fetch(url).then(res => res.arrayBuffer())
: undefined;
let exifData: ExifData | undefined;
let filmSimulation: FilmSimulation | undefined;
let blurData: string | undefined;
let imageResizedBase64: string | undefined;
if (fileBytes) {
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
@ -50,6 +66,14 @@ export const extractExifDataFromBlobPath = async (
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
}
}
if (generateBlurData) {
blurData = await blurImage(fileBytes);
}
if (generateResizedImage) {
imageResizedBase64 = await resizeImage(fileBytes);
}
}
return {
@ -62,8 +86,41 @@ export const extractExifDataFromBlobPath = async (
extension,
url,
},
...generateBlurData && { blurData },
...convertExifToFormData(exifData, filmSimulation),
},
},
imageResizedBase64,
};
};
const generateBase64 = async (
image: ArrayBuffer,
middleware: (sharp: Sharp) => Sharp,
) =>
middleware(sharp(image))
.toFormat('jpeg', { quality: 90 })
.toBuffer()
.then(data => `data:image/jpeg;base64,${data.toString('base64')}`);
const resizeImage = async (image: ArrayBuffer) =>
generateBase64(image, sharp => sharp
.resize(IMAGE_WIDTH_RESIZE)
);
const blurImage = async (image: ArrayBuffer) =>
generateBase64(image, sharp => sharp
.resize(IMAGE_WIDTH_BLUR)
.modulate({ saturation: 1.15 })
.blur(4)
);
export const resizeImageFromUrl = async (url: string) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => resizeImage(buffer));
export const blurImageFromUrl = async (url: string) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => blurImage(buffer));

View File

@ -23,3 +23,8 @@ export const getNextImageUrlForRequest = (
return url.toString();
};
// Generate small, low-bandwidth images for quick manipulations such as
// generating blur data or image thumbnails for AI text generation
export const getNextImageUrlForManipulation = (imageUrl: string) =>
getNextImageUrlForRequest(imageUrl, 640, 90);

View File

@ -34,10 +34,10 @@ export default function Footer() {
? [<div
key="footer"
className={clsx(
'flex items-center',
'flex items-center gap-1',
'text-dim min-h-10',
)}>
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
<div className="flex gap-x-3 xs:gap-x-4 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&

View File

@ -13,7 +13,6 @@ import {
BiPencil,
BiRefresh,
} from 'react-icons/bi';
import IconButton from '@/components/IconButton';
import InfoBlock from '@/components/InfoBlock';
import Checklist from '@/components/Checklist';
import { toastSuccess } from '@/toast';
@ -21,6 +20,7 @@ import { ConfigChecklistStatus } from './config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
import { HiSparkles } from 'react-icons/hi';
import LoaderButton from '@/components/primitives/LoaderButton';
export default function SiteChecklistClient({
hasDatabase,
@ -94,13 +94,17 @@ export default function SiteChecklistClient({
</>;
const renderCopyButton = (label: string, text: string, subtle?: boolean) =>
<IconButton
<LoaderButton
icon={<BiCopy size={15} />}
className={clsx(subtle && 'text-gray-300 dark:text-gray-700')}
className={clsx(
'translate-y-[2px]',
subtle && 'text-gray-300 dark:text-gray-700',
)}
onClick={() => {
navigator.clipboard.writeText(text);
toastSuccess(`${label} copied to clipboard`);
}}
styleAsLink
/>;
const renderEnvVar = (
@ -236,11 +240,12 @@ export default function SiteChecklistClient({
<span>{secret}</span>
<div className="flex items-center gap-0.5">
{renderCopyButton('Secret', secret)}
<IconButton
<LoaderButton
icon={<BiRefresh size={18} />}
onClick={refreshSecret}
isLoading={isPendingSecret}
spinnerColor="text"
styleAsLink
/>
</div>
</div>

View File

@ -137,8 +137,10 @@ export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1;
export const CONFIG_CHECKLIST_STATUS = {
hasDatabase: HAS_DATABASE,
isPostgresSSLEnabled: POSTGRES_SSL_ENABLED,
hasVercelPostgres: /\.vercel-storage\.com\//
.test(process.env.POSTGRES_URL ?? ''),
hasVercelPostgres: (
/\/verceldb\?/.test(process.env.POSTGRES_URL ?? '') ||
/\.vercel-storage\.com\//.test(process.env.POSTGRES_URL ?? '')
),
hasVercelKV: HAS_VERCEL_KV,
hasVercelBlobStorage: HAS_VERCEL_BLOB_STORAGE,
hasCloudflareR2Storage: HAS_CLOUDFLARE_R2_STORAGE,

View File

@ -77,12 +77,13 @@
cursor-pointer
hover:no-underline
inline-flex gap-2 items-center
px-4
px-3
text-base
shadow-sm
active:bg-gray-100 dark:active:bg-gray-900
hover:border-gray-300 dark:hover:border-gray-600
disabled:cursor-not-allowed
disabled:text-dim
disabled:bg-gray-100 dark:disabled:bg-gray-900
disabled:border-gray-200 disabled:dark:border-gray-700
}
@ -111,7 +112,7 @@
button.link {
@apply
p-0 min-h-0
border-none bg-transparent active:bg-transparent shadow-none
border-none bg-transparent active:bg-transparent shadow-none rounded-none
}
a, .link {
@apply

View File

@ -20,6 +20,8 @@ export interface AppStateContext {
registerAdminUpdate?: () => void
shouldShowBaselineGrid?: boolean
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
shouldDebugBlur?: boolean
setShouldDebugBlur?: Dispatch<SetStateAction<boolean>>
clearNextPhotoAnimation?: () => void
}

View File

@ -29,6 +29,8 @@ export default function AppStateProvider({
const [adminUpdateTimes, setAdminUpdateTimes] = useState<Date[]>([]);
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
useState(false);
const [shouldDebugBlur, setShouldDebugBlur] =
useState(false);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
@ -63,6 +65,8 @@ export default function AppStateProvider({
adminUpdateTimes,
registerAdminUpdate,
shouldShowBaselineGrid,
shouldDebugBlur,
setShouldDebugBlur,
setShouldShowBaselineGrid,
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
}}