diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e710f6c..8b5d2aae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,6 +46,7 @@ "upstash", "UsKSGcbt", "Velvia", + "verceldb", "WRHGZC", "wxyz", "zadd", diff --git a/README.md b/README.md index 09e8faf5..857594d3 100644 --- a/README.md +++ b/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." diff --git a/package.json b/package.json index 9ef6f8ec..8a0d7541 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 777cae6e..86345a66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/admin/AddButton.tsx b/src/admin/AddButton.tsx index b1bdede6..cba511a6 100644 --- a/src/admin/AddButton.tsx +++ b/src/admin/AddButton.tsx @@ -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 ( - } > - - - {label} - - + Add + ); } diff --git a/src/admin/AdminPhotoTable.tsx b/src/admin/AdminPhotosTable.tsx similarity index 94% rename from src/admin/AdminPhotoTable.tsx rename to src/admin/AdminPhotosTable.tsx index 4354bf8c..f4effb3f 100644 --- a/src/admin/AdminPhotoTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -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', )}> - + } + icon={} onFormSubmitToastMessage={` "${titleForPhoto(photo)}" EXIF data synced `} diff --git a/src/admin/AdminPhotoTableInfinite.tsx b/src/admin/AdminPhotosTableInfinite.tsx similarity index 82% rename from src/admin/AdminPhotoTableInfinite.tsx rename to src/admin/AdminPhotosTableInfinite.tsx index ab8bf1e9..16975bb7 100644 --- a/src/admin/AdminPhotoTableInfinite.tsx +++ b/src/admin/AdminPhotosTableInfinite.tsx @@ -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 }) => - - + - + } + icon={} onFormSubmit={invalidateSwr} > Clear Cache diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx index 7c570903..7c048beb 100644 --- a/src/admin/DeleteButton.tsx +++ b/src/admin/DeleteButton.tsx @@ -14,6 +14,7 @@ export default function DeleteButton ( const { onFormSubmit: onFormSubmitProps, clearLocalState, + className, ...rest } = props; @@ -30,11 +31,13 @@ export default function DeleteButton ( return } + icon={} 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', )} diff --git a/src/admin/EditButton.tsx b/src/admin/EditButton.tsx index 113aec65..6234bd4d 100644 --- a/src/admin/EditButton.tsx +++ b/src/admin/EditButton.tsx @@ -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 ( - } > - - - {label} - - + Edit + ); } diff --git a/src/app/admin/photos/[photoId]/edit/page.tsx b/src/app/admin/photos/[photoId]/edit/page.tsx index c9a5c68c..8affc888 100644 --- a/src/app/admin/photos/[photoId]/edit/page.tsx +++ b/src/app/admin/photos/[photoId]/edit/page.tsx @@ -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 ( ); }; diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index 9cdd2f29..3a5dd722 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -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', )}> - }
- + {photosCount > photos.length && - } diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx index 1092ed7d..e9e252b0 100644 --- a/src/app/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/admin/uploads/[uploadPath]/page.tsx @@ -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, }} /> ); }; diff --git a/src/app/admin/uploads/page.tsx b/src/app/admin/uploads/page.tsx index ce1273c3..e4fecd0f 100644 --- a/src/app/admin/uploads/page.tsx +++ b/src/app/admin/uploads/page.tsx @@ -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 ( } + contentMain={} /> ); } diff --git a/src/components/CanvasBlurCapture.tsx b/src/components/CanvasBlurCapture.tsx deleted file mode 100644 index cce1ed31..00000000 --- a/src/components/CanvasBlurCapture.tsx +++ /dev/null @@ -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(null); - const refImage = useRef(typeof Image !== 'undefined' ? new Image() : null); - const refTimeouts = useRef([]); - 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 ( - - ); -} diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx index 5286062a..7c566112 100644 --- a/src/components/CommandKClient.tsx +++ b/src/components/CommandKClient.tsx @@ -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: , items: [{ + label: 'Toggle Blur Debug', + action: () => setShouldDebugBlur?.(prev => !prev), + annotation: shouldDebugBlur ? : undefined, + }, { label: 'Toggle Baseline Grid', action: () => setShouldShowBaselineGrid?.(prev => !prev), + annotation: shouldShowBaselineGrid ? : undefined, }], }); } diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx deleted file mode 100644 index b446752f..00000000 --- a/src/components/IconButton.tsx +++ /dev/null @@ -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 ( - - {!isLoading - ? - : - - } - - ); -} diff --git a/src/components/ImageBlurFallback.tsx b/src/components/ImageBlurFallback.tsx index 6f5072b7..acdd58fc 100644 --- a/src/components/ImageBlurFallback.tsx +++ b/src/components/ImageBlurFallback.tsx @@ -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 (
- {showPlaceholder && + {(showPlaceholder || shouldDebugBlur) &&
{(BLUR_ENABLED && props.blurDataURL) ? :
diff --git a/src/components/ImageSmall.tsx b/src/components/ImageSmall.tsx index d13cb0e7..b03489f5 100644 --- a/src/components/ImageSmall.tsx +++ b/src/components/ImageSmall.tsx @@ -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), }} /> diff --git a/src/components/ImageTiny.tsx b/src/components/ImageTiny.tsx index 5e976775..9b319f50 100644 --- a/src/components/ImageTiny.tsx +++ b/src/components/ImageTiny.tsx @@ -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 ( diff --git a/src/components/PageSpinner.tsx b/src/components/PageSpinner.tsx index cd4c99af..630f5821 100644 --- a/src/components/PageSpinner.tsx +++ b/src/components/PageSpinner.tsx @@ -1,4 +1,4 @@ -import clsx from 'clsx/lite'; +import { clsx } from 'clsx/lite'; import Spinner from './Spinner'; import SiteGrid from './SiteGrid'; diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 02057aac..b0af3664 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -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 ( - , - prefetch, - shouldScroll, - shouldReplace: true, - spinnerColor: 'dim', - }} /> + } + spinnerColor="dim" + prefetch={prefetch} + shouldScroll={shouldScroll} + shouldReplace + styleAsLink + /> ); } diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index 514b138d..502abb22 100644 --- a/src/components/SubmitButtonWithStatus.tsx +++ b/src/components/SubmitButtonWithStatus.tsx @@ -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 { icon?: JSX.Element @@ -49,34 +50,21 @@ export default function SubmitButtonWithStatus({ }, [onFormStatusChange, pending]); return ( - + {children} + ); }; diff --git a/src/components/primitives/LoaderButton.tsx b/src/components/primitives/LoaderButton.tsx new file mode 100644 index 00000000..1abc4993 --- /dev/null +++ b/src/components/primitives/LoaderButton.tsx @@ -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) { + const { + children, + isLoading, + icon, + spinnerColor, + styleAsLink, + type = 'button', + disabled, + className, + ...rest + } = props; + return ( + + ); +} diff --git a/src/components/IconPathButton.tsx b/src/components/primitives/PathLoaderButton.tsx similarity index 68% rename from src/components/IconPathButton.tsx rename to src/components/primitives/PathLoaderButton.tsx index 4108d89d..dc61947a 100644 --- a/src/components/IconPathButton.tsx +++ b/src/components/primitives/PathLoaderButton.tsx @@ -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 ( - 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} + ); } diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index 218d35a4..77b83492 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -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 ( } @@ -168,6 +170,10 @@ export default function PhotoLarge({ {photo.takenAtNaiveFormatted}
- - } + } diff --git a/src/photo/UpdateBlurDataButton.tsx b/src/photo/UpdateBlurDataButton.tsx new file mode 100644 index 00000000..104baa39 --- /dev/null +++ b/src/photo/UpdateBlurDataButton.tsx @@ -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 ( + + ); +} diff --git a/src/photo/UploadPageClient.tsx b/src/photo/UploadPageClient.tsx index 3a070d55..cbd106f1 100644 --- a/src/photo/UploadPageClient.tsx +++ b/src/photo/UploadPageClient.tsx @@ -16,12 +16,14 @@ export default function UploadPageClient({ uniqueTags, hasAiTextGeneration, textFieldsToAutoGenerate, + imageThumbnailBase64, }: { blobId?: string photoFormExif: Partial 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, diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 21dcc7f4..f2d190dc 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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, -): Promise> { - return safelyRunAdminServerAction(async () => { +): Promise> => + 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)); diff --git a/src/photo/ai/AiButton.tsx b/src/photo/ai/AiButton.tsx index b88d1fb0..f7fbdbe7 100644 --- a/src/photo/ai/AiButton.tsx +++ b/src/photo/ai/AiButton.tsx @@ -56,7 +56,7 @@ export default function AiButton({ e.preventDefault(); } }} - disabled={!aiContent.isReady || isLoading} + disabled={isLoading} > {isLoading ? : } diff --git a/src/photo/ai/useAiImageQueries.ts b/src/photo/ai/useAiImageQueries.ts index 3071dc64..8c78ab7c 100644 --- a/src/photo/ai/useAiImageQueries.ts +++ b/src/photo/ai/useAiImageQueries.ts @@ -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; export default function useAiImageQueries( textFieldsToAutoGenerate: AiAutoGeneratedField[] = [], + imageData?: string, ) { - const [imageData, setImageData] = useState(); - - 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, }; } diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 7e0e7f26..e180b6e2 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -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 updatedExifData?: Partial - 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>(initialPhotoForm); const [formErrors, setFormErrors] = useState(getFormErrors(initialPhotoForm)); - const [blurError, setBlurError] = - useState(); - 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 + ? + setFormData(data => ({ ...data, blurData }))} + /> + : null; } } }; + const shouldHideField = ( + key: keyof PhotoDbInsert | 'favorite', + hideIfEmpty?: boolean, + shouldHide?: (formData: Partial) => boolean, + ) => { + if (key === 'blurData' && type === 'create' && !shouldDebugBlur) { + return true; + } else { + return ( + (hideIfEmpty && !formData[key]) || + shouldHide?.(formData) + ); + } + }; + return (
- {debugBlur && blurError && -
- {blurError} -
}
Analyzing image
- - {debugBlur && formData.blurData && - blur}
- ( - (!hideIfEmpty || formData[key]) && - !shouldHide?.(formData) - ) && + !shouldHideField(key, hideIfEmpty, shouldHide) && )}
{/* Actions */} diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 5c772333..9b9cf7ab 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -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]; } diff --git a/src/photo/form/usePhotoFormParent.ts b/src/photo/form/usePhotoFormParent.ts index bf3fe83a..3ab89b9b 100644 --- a/src/photo/form/usePhotoFormParent.ts +++ b/src/photo/form/usePhotoFormParent.ts @@ -6,16 +6,21 @@ import { AiAutoGeneratedField } from '../ai'; export default function usePhotoFormParent({ photoForm, textFieldsToAutoGenerate, + imageThumbnailBase64, }: { - photoForm?: Partial, - textFieldsToAutoGenerate?: AiAutoGeneratedField[], -} = {}) { + photoForm?: Partial + 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, diff --git a/src/photo/index.ts b/src/photo/index.ts index 0c94abd5..122b5b63 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -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')); diff --git a/src/photo/server.ts b/src/photo/server.ts index 96fae675..2f0bc5b9 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -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 + 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)); diff --git a/src/services/next-image.ts b/src/services/next-image.ts index 9b8cc116..13ce7ffe 100644 --- a/src/services/next-image.ts +++ b/src/services/next-image.ts @@ -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); diff --git a/src/site/Footer.tsx b/src/site/Footer.tsx index f598ec38..f874bc62 100644 --- a/src/site/Footer.tsx +++ b/src/site/Footer.tsx @@ -34,10 +34,10 @@ export default function Footer() { ? [
-
+
{isPathAdmin(pathname) ? <> {userEmail === undefined && diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index c62075c2..7e6ea602 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -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) => - } - 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({ {secret}
{renderCopyButton('Secret', secret)} - } onClick={refreshSecret} isLoading={isPendingSecret} spinnerColor="text" + styleAsLink />
diff --git a/src/site/config.ts b/src/site/config.ts index b4293231..b1d3da1f 100644 --- a/src/site/config.ts +++ b/src/site/config.ts @@ -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, diff --git a/src/site/globals.css b/src/site/globals.css index 5052b267..c4b8cf43 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -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 diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 94d90b49..83f3ceb4 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -20,6 +20,8 @@ export interface AppStateContext { registerAdminUpdate?: () => void shouldShowBaselineGrid?: boolean setShouldShowBaselineGrid?: Dispatch> + shouldDebugBlur?: boolean + setShouldDebugBlur?: Dispatch> clearNextPhotoAnimation?: () => void } diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index ac4ecf5b..57c24a14 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -29,6 +29,8 @@ export default function AppStateProvider({ const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); 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), }}