Merge branch 'main' into ppr-static
This commit is contained in:
commit
e79a053b6d
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -46,6 +46,7 @@
|
||||
"upstash",
|
||||
"UsKSGcbt",
|
||||
"Velvia",
|
||||
"verceldb",
|
||||
"WRHGZC",
|
||||
"wxyz",
|
||||
"zadd",
|
||||
|
||||
19
README.md
19
README.md
@ -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."
|
||||
|
||||
@ -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
156
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
`}
|
||||
@ -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}
|
||||
@ -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={
|
||||
|
||||
@ -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?"
|
||||
@ -11,7 +11,7 @@ export default function ClearCacheButton() {
|
||||
return (
|
||||
<form action={syncCacheAction}>
|
||||
<SubmitButtonWithStatus
|
||||
icon={<BiTrash />}
|
||||
icon={<BiTrash size={16} />}
|
||||
onFormSubmit={invalidateSwr}
|
||||
>
|
||||
Clear Cache
|
||||
|
||||
@ -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',
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>}
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import clsx from 'clsx/lite';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import Spinner from './Spinner';
|
||||
import SiteGrid from './SiteGrid';
|
||||
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
56
src/components/primitives/LoaderButton.tsx
Normal file
56
src/components/primitives/LoaderButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
36
src/photo/UpdateBlurDataButton.tsx
Normal file
36
src/photo/UpdateBlurDataButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -56,7 +56,7 @@ export default function AiButton({
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
disabled={!aiContent.isReady || isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
|
||||
</button>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -20,6 +20,8 @@ export interface AppStateContext {
|
||||
registerAdminUpdate?: () => void
|
||||
shouldShowBaselineGrid?: boolean
|
||||
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugBlur?: boolean
|
||||
setShouldDebugBlur?: Dispatch<SetStateAction<boolean>>
|
||||
clearNextPhotoAnimation?: () => void
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user