Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-03-22 17:59:36 -05:00
commit 12c418079c
36 changed files with 1621 additions and 211 deletions

10
.vscode/settings.json vendored
View File

@ -10,6 +10,7 @@
"cloudflarestorage",
"cmdk",
"CredentialsSignin",
"datetime",
"Eterna",
"exif",
"exifr",
@ -20,9 +21,11 @@
"headlessui",
"hgetall",
"hset",
"ILIKE",
"jpgs",
"Lightbox",
"Makernote",
"mitigations",
"nanoids",
"nextjs",
"parameterizes",
@ -30,19 +33,22 @@
"Provia",
"qaub",
"QRSTUVWXYZ",
"ratelimit",
"ratelimiter",
"Reala",
"skippable",
"sonner",
"Streamable",
"thephotoblog",
"trpc",
"unnest",
"upstash",
"UsKSGcbt",
"Velvia",
"WRHGZC",
"wxyz",
"zadd",
"zrange",
"datetime"
"zrange"
],
"files.associations": {
"*.css": "tailwindcss"

View File

@ -10,12 +10,14 @@ https://photos.sambecker.com
Features
-
- Built-in auth
- Photo upload with EXIF extraction
- Organize photos by tag and camera model
- Infinite scroll
- Built-in auth
- Light/dark mode
- CMD-K menu with photo search
- Automatic OG image generation
- Experimental support for AI-generated descriptions
- Support for Fujifilm simulations
<img src="/readme/og-image-share.png" alt="OG Image Preview" width=600 />
@ -66,7 +68,25 @@ Installation
2. Click "Speed Insights" tab
3. Follow "Enable Speed Insights" instructions (`@vercel/speed-insights` already included)
### 7. Optional configuration
### 7. Add experimental AI text generation
_⚠ READ BEFORE PROCEEDING_
> _Usage of this feature will result in fees from OpenAI. When enabling AI text generation, follow all recommended mitigations in order to avoid unexpected charges and attacks. Make sure your OpenAI secret key environment variable is not prefixed with NEXT_PUBLIC._
1. Setup OpenAI
- If you don't already have one, create an [OpenAI](https://openai.com) account
- Generate an API key and store in environment variable `OPENAI_SECRET_KEY`
- Setup usage limits to avoid unexpected charges (_recommended_)
2. Add rate limiting (_recommended_)
- As an additional precaution, create a [Vercel KV](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) store and link it to your project in order to enable rate limiting
3. Configure auto-generated fields (optional)
- Set which text fields should auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title, semantic`
- Accepted values: title, caption, tags, description, all, or none (default is "all")
### 8. Optional configuration
Application behavior can be changed by configuring the following environment variables:
- `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage for jpgs (results in increased storage usage)
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE = 1` enables PPR and static optimization, i.e., building pages ahead of time (results in increased storage usage)—⚠️ _Experimental_

42
__tests__/ai.test.ts Normal file
View File

@ -0,0 +1,42 @@
/* eslint-disable quotes */
import { parseTitleAndCaption } from "@/photo/ai";
describe('AI text parses', () => {
it('titles and captions', () => {
// Complex case
expect(parseTitleAndCaption(
`'Title: "Ephemeral Beauty" Caption: "Roses bask in fleeting sunlight."'`
)).toStrictEqual({
title: 'Ephemeral Beauty',
caption: 'Roses bask in fleeting sunlight',
});
// Without surrounding single quotes
expect(parseTitleAndCaption(
`Title: "Ephemeral Beauty" Caption: "Roses bask in fleeting sunlight."`
)).toStrictEqual({
title: 'Ephemeral Beauty',
caption: 'Roses bask in fleeting sunlight',
});
// Without trailing period
expect(parseTitleAndCaption(
`Title: "Ephemeral Beauty" Caption: "Roses bask in fleeting sunlight"`
)).toStrictEqual({
title: 'Ephemeral Beauty',
caption: 'Roses bask in fleeting sunlight',
});
// Without and quotes
expect(parseTitleAndCaption(
`Title: Ephemeral Beauty Caption: Roses bask in fleeting sunlight`
)).toStrictEqual({
title: 'Ephemeral Beauty',
caption: 'Roses bask in fleeting sunlight',
});
// With single space
expect(parseTitleAndCaption(
`Title: Ephemeral Beauty Caption: Roses bask in fleeting sunlight`
)).toStrictEqual({
title: 'Ephemeral Beauty',
caption: 'Roses bask in fleeting sunlight',
});
});
});

View File

@ -23,10 +23,13 @@
"@types/react-dom": "18.2.21",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@upstash/ratelimit": "^1.0.1",
"@vercel/analytics": "^1.2.2",
"@vercel/blob": "^0.22.1",
"@vercel/kv": "^1.0.1",
"@vercel/postgres": "0.7.2",
"@vercel/speed-insights": "^1.0.10",
"ai": "^3.0.13",
"autoprefixer": "10.4.18",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.0",
@ -39,9 +42,10 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.6",
"next": "14.2.0-canary.31",
"next": "14.2.0-canary.39",
"next-auth": "5.0.0-beta.15",
"next-themes": "^0.3.0",
"openai": "^4.29.2",
"postcss": "8.4.35",
"react": "18.2.0",
"react-dom": "18.2.0",

613
pnpm-lock.yaml generated
View File

@ -47,18 +47,27 @@ dependencies:
'@typescript-eslint/parser':
specifier: ^7.2.0
version: 7.2.0(eslint@8.57.0)(typescript@5.4.2)
'@upstash/ratelimit':
specifier: ^1.0.1
version: 1.0.1
'@vercel/analytics':
specifier: ^1.2.2
version: 1.2.2(next@14.2.0-canary.31)(react@18.2.0)
version: 1.2.2(next@14.2.0-canary.39)(react@18.2.0)
'@vercel/blob':
specifier: ^0.22.1
version: 0.22.1
'@vercel/kv':
specifier: ^1.0.1
version: 1.0.1
'@vercel/postgres':
specifier: 0.7.2
version: 0.7.2
'@vercel/speed-insights':
specifier: ^1.0.10
version: 1.0.10(next@14.2.0-canary.31)(react@18.2.0)
version: 1.0.10(next@14.2.0-canary.39)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21)
ai:
specifier: ^3.0.13
version: 3.0.13(react@18.2.0)(solid-js@1.8.15)(svelte@4.2.12)(vue@3.4.21)(zod@3.22.4)
autoprefixer:
specifier: 10.4.18
version: 10.4.18(postcss@8.4.35)
@ -96,14 +105,17 @@ dependencies:
specifier: ^5.0.6
version: 5.0.6
next:
specifier: 14.2.0-canary.31
version: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
specifier: 14.2.0-canary.39
version: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next-auth:
specifier: 5.0.0-beta.15
version: 5.0.0-beta.15(next@14.2.0-canary.31)(react@18.2.0)
version: 5.0.0-beta.15(next@14.2.0-canary.39)(react@18.2.0)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.2.0)(react@18.2.0)
openai:
specifier: ^4.29.2
version: 4.29.2
postcss:
specifier: 8.4.35
version: 8.4.35
@ -1551,8 +1563,8 @@ packages:
- utf-8-validate
dev: false
/@next/env@14.2.0-canary.31:
resolution: {integrity: sha512-xdUjSv8c5e1QPiB010TcyW1zPL3bK7FySHQDu6NjzZuUkYwm8W9c9NGIdJLB2UQv0rfpaFBKfWNlGbakicrE+g==}
/@next/env@14.2.0-canary.39:
resolution: {integrity: sha512-ROeqwq9mybhzfdzNDbz9/0e3fFB6gtC25NZNC/rhZzvgkTvUuYXUbJOJSvvtsoUjQolTCFOhZqKmopX+QgwYwQ==}
dev: false
/@next/eslint-plugin-next@14.1.3:
@ -1561,8 +1573,8 @@ packages:
glob: 10.3.10
dev: false
/@next/swc-darwin-arm64@14.2.0-canary.31:
resolution: {integrity: sha512-9NRPNOWxY/ecv1hxZej9nVvggIemdCqkwlgmkVv+M2TkAJzff5bwY4IzTuyPxQmioxHs43gPyKh/wpVsH4cuog==}
/@next/swc-darwin-arm64@14.2.0-canary.39:
resolution: {integrity: sha512-ImAEFQBac/jYFCQYAEOxLZlzZfoa0GnbmXlGruzyNXl7RG3gJ3OBXx6G/puySAdytp54tArmr+0h+xoEXbop2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -1570,8 +1582,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@14.2.0-canary.31:
resolution: {integrity: sha512-8xbOircQJzJx39GZ4iNd/6PIyOI/qZ8TpjJ9qzA1FpVZUDAMXZIDnQIHIsnxvn0HkP85PByw3tcKZLLKcw5k4g==}
/@next/swc-darwin-x64@14.2.0-canary.39:
resolution: {integrity: sha512-2q0F3L261vYPOrn7KXLX5SzfMe8yPRs0plnExpV2MwQjikt5OhlUdGwRRyEFT0DS0S0cyaKw00nENxBuDi7VyA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -1579,8 +1591,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@14.2.0-canary.31:
resolution: {integrity: sha512-zpA19+op2KxnIutpoZbvQiplmoJHsUUPIhynC+PpZtSqA1IWBZoem+T4IhnmHr+F0DQZRqO9lmZZkHdEpRAzbQ==}
/@next/swc-linux-arm64-gnu@14.2.0-canary.39:
resolution: {integrity: sha512-efraDAfAjQosfUdW8ZMjnrH3/mveQQxs055BdGfh+L0+hlTf05ECUH07tg3AKqihhnk+sgJUqigR5ZSsUYrqsw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1588,8 +1600,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@14.2.0-canary.31:
resolution: {integrity: sha512-cZ1GuBi6YOHdEw8/lhuxZsObJVpry7irgf1PCsOLigpIqCAyMhbAcQ32FkTA3N5MKWpOkrmS9xQ2B6K1ZaQzCg==}
/@next/swc-linux-arm64-musl@14.2.0-canary.39:
resolution: {integrity: sha512-Eb6+d3XkhwaEd69OoTOa4/scqQJtCUiZrmWjR0sVbW3QJ0wWu2o5gz8mInYsLeLwxN+HDy1aDuQSl3hp2PwBbQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1597,8 +1609,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@14.2.0-canary.31:
resolution: {integrity: sha512-YxWC/fipzs/3cTeGcsSSU3GXEAJ16TI3yo4jgwofWko2jSF2/kpCOZSnRYbqX3eNyBCD4K9/g/9v6rAx2zHiew==}
/@next/swc-linux-x64-gnu@14.2.0-canary.39:
resolution: {integrity: sha512-HKkx1WCMsycDFOp76avVMCIGm/E0jw3yugfyIc/g1vRIh6fTOZ9iyLd1Uannu4MorTxGWS4g1ZRr1C5/9Ve8kg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1606,8 +1618,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@14.2.0-canary.31:
resolution: {integrity: sha512-CoZVZyx08myeDXmoSEuk+eXrwMYenevXP03Rz/+6+BT6zOrq+1s1rFKUZVd6/3AnD9Q7vNCuTGtaL5jaF5koGw==}
/@next/swc-linux-x64-musl@14.2.0-canary.39:
resolution: {integrity: sha512-9W/UTFugvG0fYhNK5IqahiwldH3JSXmF2iCzQMbGMpyhjvOn1UirEZPwkMXz6tdSGXVHwxvvsuhZhZgBIt8csw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1615,8 +1627,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@14.2.0-canary.31:
resolution: {integrity: sha512-m1nuZmu8DOJKJvVrrz7KxMH1K3IU1UC7av1jD55cFf3ZM5ur06Mx2PvtbKSnSLCjK7Ga8LHMYXBXQWAbkD6Bcg==}
/@next/swc-win32-arm64-msvc@14.2.0-canary.39:
resolution: {integrity: sha512-rtG2wYP3Sa67F2AqaX2qISefZbc/KN0fj5gPx3ReFIuK8/p6tR/L063xvyNmBZs22DZuc07EaFCQ9Px7EB0C2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -1624,8 +1636,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@14.2.0-canary.31:
resolution: {integrity: sha512-OFmjN8wK6eSViHUqsh7VLzI8H8d3G6esjn3zOHoSirg821MJLyWVGAXMBjykDz4kTP6VmGdCkohBP6nf/uy94Q==}
/@next/swc-win32-ia32-msvc@14.2.0-canary.39:
resolution: {integrity: sha512-Qh3vNCQQqghFuX4XhKuBhlleaRNIVFTspFMMKdQKFoATVVZh5n/PEeGEIgwjZjsjwfLPI82fkIvxhZkPujcAgg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -1633,8 +1645,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@14.2.0-canary.31:
resolution: {integrity: sha512-Dv+FC2zYh8aEKsFUpq6815grRS0dcRw9uJ9hxULAZ9EuFcw0iu5zKbEPWZ+klxDrAWA8bw7WYMZfkvEH7bSLOA==}
/@next/swc-win32-x64-msvc@14.2.0-canary.39:
resolution: {integrity: sha512-CPFzgcPYamtJpHtrHr55LsZ9g95l9vnm85OckaDQCK+359z4sgWk5Jp2ortPN/ZorDk+KjiixrE8x1Ix07Mk9g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -2785,6 +2797,14 @@ packages:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
dev: false
/@types/diff-match-patch@1.0.36:
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
dev: false
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
dev: false
/@types/graceful-fs@4.1.9:
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
dependencies:
@ -2830,6 +2850,19 @@ packages:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: false
/@types/node-fetch@2.6.11:
resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
dependencies:
'@types/node': 20.11.26
form-data: 4.0.0
dev: false
/@types/node@18.19.24:
resolution: {integrity: sha512-eghAz3gnbQbvnHqB+mgB2ZR3aH6RhdEmHGS48BnV75KceQPHqabkxKI0BbUSsqhqy2Ddhc2xD/VAR9ySZd57Lw==}
dependencies:
undici-types: 5.26.5
dev: false
/@types/node@20.11.26:
resolution: {integrity: sha512-YwOMmyhNnAWijOBQweOJnQPl068Oqd4K3OFbTc6AHJwzweUwwWG3GIFY74OKks2PJUDkQPeddOQES9mLn1CTEQ==}
dependencies:
@ -3088,7 +3121,32 @@ packages:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: false
/@vercel/analytics@1.2.2(next@14.2.0-canary.31)(react@18.2.0):
/@upstash/core-analytics@0.0.7:
resolution: {integrity: sha512-lC2j5efqb1haX/fpTGaPUx1rue1WUkOZBVHDzCB7eMIVsRdFFp4xiHtyH/G9omiR1zj39fU5SCTWFiKJH3KOpw==}
engines: {node: '>=16.0.0'}
dependencies:
'@upstash/redis': 1.28.4
dev: false
/@upstash/ratelimit@1.0.1:
resolution: {integrity: sha512-G9LZ7idhlkuYknbUngCB3qzd7QnkK1xDkFG5jRtEJZuOUS5UKJ0UTKbhalCtp39eX2wu2Ubv8W7HCeaJQOWM0A==}
dependencies:
'@upstash/core-analytics': 0.0.7
dev: false
/@upstash/redis@1.25.1:
resolution: {integrity: sha512-ACj0GhJ4qrQyBshwFgPod6XufVEfKX2wcaihsEvSdLYnY+m+pa13kGt1RXm/yTHKf4TQi/Dy2A8z/y6WUEOmlg==}
dependencies:
crypto-js: 4.2.0
dev: false
/@upstash/redis@1.28.4:
resolution: {integrity: sha512-UalkSAny/dz1m8giEhD3Y5ru1o+CPHI32wFyS3MyzDzj2TRvEN+lTw+mPwi20ojk0H2gs8TBW3qsrvwuLLy+pA==}
dependencies:
crypto-js: 4.2.0
dev: false
/@vercel/analytics@1.2.2(next@14.2.0-canary.39)(react@18.2.0):
resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==}
peerDependencies:
next: '>= 13'
@ -3099,7 +3157,7 @@ packages:
react:
optional: true
dependencies:
next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
server-only: 0.0.1
dev: false
@ -3114,6 +3172,13 @@ packages:
undici: 5.28.3
dev: false
/@vercel/kv@1.0.1:
resolution: {integrity: sha512-uTKddsqVYS2GRAM/QMNNXCTuw9N742mLoGRXoNDcyECaxEXvIHG0dEY+ZnYISV4Vz534VwJO+64fd9XeSggSKw==}
engines: {node: '>=14.6'}
dependencies:
'@upstash/redis': 1.25.1
dev: false
/@vercel/postgres@0.7.2:
resolution: {integrity: sha512-IqR/ZAvoPGcPaXl9eWWB5KaA+w/81RzZa/18P4izQRHpNBkTGt9HwGfYi9+wut5UgxNq4QSX9A7HIQR6QDvX2Q==}
engines: {node: '>=14.6'}
@ -3124,7 +3189,7 @@ packages:
ws: 8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3)
dev: false
/@vercel/speed-insights@1.0.10(next@14.2.0-canary.31)(react@18.2.0):
/@vercel/speed-insights@1.0.10(next@14.2.0-canary.39)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21):
resolution: {integrity: sha512-4uzdKB0RW6Ff2FkzshzjZ+RlJfLPxgm/00i0XXgxfMPhwnnsk92YgtqsxT9OcPLdJUyVU1DqFlSWWjIQMPkh0g==}
requiresBuild: true
peerDependencies:
@ -3148,8 +3213,83 @@ packages:
vue-router:
optional: true
dependencies:
next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
svelte: 4.2.12
vue: 3.4.21(typescript@5.4.2)
dev: false
/@vue/compiler-core@3.4.21:
resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==}
dependencies:
'@babel/parser': 7.23.9
'@vue/shared': 3.4.21
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.0.2
dev: false
/@vue/compiler-dom@3.4.21:
resolution: {integrity: sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==}
dependencies:
'@vue/compiler-core': 3.4.21
'@vue/shared': 3.4.21
dev: false
/@vue/compiler-sfc@3.4.21:
resolution: {integrity: sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==}
dependencies:
'@babel/parser': 7.23.9
'@vue/compiler-core': 3.4.21
'@vue/compiler-dom': 3.4.21
'@vue/compiler-ssr': 3.4.21
'@vue/shared': 3.4.21
estree-walker: 2.0.2
magic-string: 0.30.8
postcss: 8.4.35
source-map-js: 1.0.2
dev: false
/@vue/compiler-ssr@3.4.21:
resolution: {integrity: sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==}
dependencies:
'@vue/compiler-dom': 3.4.21
'@vue/shared': 3.4.21
dev: false
/@vue/reactivity@3.4.21:
resolution: {integrity: sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==}
dependencies:
'@vue/shared': 3.4.21
dev: false
/@vue/runtime-core@3.4.21:
resolution: {integrity: sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==}
dependencies:
'@vue/reactivity': 3.4.21
'@vue/shared': 3.4.21
dev: false
/@vue/runtime-dom@3.4.21:
resolution: {integrity: sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==}
dependencies:
'@vue/runtime-core': 3.4.21
'@vue/shared': 3.4.21
csstype: 3.1.3
dev: false
/@vue/server-renderer@3.4.21(vue@3.4.21):
resolution: {integrity: sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==}
peerDependencies:
vue: 3.4.21
dependencies:
'@vue/compiler-ssr': 3.4.21
'@vue/shared': 3.4.21
vue: 3.4.21(typescript@5.4.2)
dev: false
/@vue/shared@3.4.21:
resolution: {integrity: sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g==}
dev: false
/abab@2.0.6:
@ -3157,6 +3297,13 @@ packages:
deprecated: Use your platform's native atob() and btoa() methods instead
dev: false
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: false
/acorn-globals@7.0.1:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies:
@ -3192,6 +3339,50 @@ packages:
- supports-color
dev: false
/agentkeepalive@4.5.0:
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
engines: {node: '>= 8.0.0'}
dependencies:
humanize-ms: 1.2.1
dev: false
/ai@3.0.13(react@18.2.0)(solid-js@1.8.15)(svelte@4.2.12)(vue@3.4.21)(zod@3.22.4):
resolution: {integrity: sha512-fDrYnVTdMJuS/qYUq0T/CX3WDuTfcZFie9LkgnoQ2layfUG2Wzh/mpfkfYXFEq/mqnpep3xUtECOB1weyyvwUg==}
engines: {node: '>=14.6'}
peerDependencies:
react: ^18.2.0
solid-js: ^1.7.7
svelte: ^3.0.0 || ^4.0.0
vue: ^3.3.4
zod: ^3.0.0
peerDependenciesMeta:
react:
optional: true
solid-js:
optional: true
svelte:
optional: true
vue:
optional: true
zod:
optional: true
dependencies:
eventsource-parser: 1.0.0
jsondiffpatch: 0.6.0
nanoid: 3.3.6
react: 18.2.0
solid-js: 1.8.15
solid-swr-store: 0.10.7(solid-js@1.8.15)(swr-store@0.10.6)
sswr: 2.0.0(svelte@4.2.12)
svelte: 4.2.12
swr: 2.2.0(react@18.2.0)
swr-store: 0.10.6
swrv: 1.0.4(vue@3.4.21)
vue: 3.4.21(typescript@5.4.2)
zod: 3.22.4
zod-to-json-schema: 3.22.4(zod@3.22.4)
dev: false
/ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
dependencies:
@ -3429,6 +3620,12 @@ packages:
dequal: 2.0.3
dev: false
/axobject-query@4.0.0:
resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==}
dependencies:
dequal: 2.0.3
dev: false
/babel-jest@29.7.0(@babel/core@7.23.9):
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -3505,6 +3702,10 @@ packages:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: false
/base-64@0.1.0:
resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==}
dev: false
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
@ -3660,11 +3861,20 @@ packages:
supports-color: 7.2.0
dev: false
/chalk@5.3.0:
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
dev: false
/char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
dev: false
/charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
dev: false
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -3727,6 +3937,16 @@ packages:
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
dev: false
/code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
'@types/estree': 1.0.5
acorn: 8.11.3
estree-walker: 3.0.3
periscopic: 3.1.0
dev: false
/collect-v8-coverage@1.0.2:
resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==}
dev: false
@ -3810,6 +4030,22 @@ packages:
which: 2.0.2
dev: false
/crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
dev: false
/crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
dev: false
/css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
dependencies:
mdn-data: 2.0.30
source-map-js: 1.0.2
dev: false
/css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
dev: false
@ -3971,11 +4207,22 @@ packages:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: false
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: false
/diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dev: false
/digest-fetch@1.3.0:
resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==}
dependencies:
base-64: 0.1.0
md5: 2.3.0
dev: false
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@ -4513,11 +4760,31 @@ packages:
engines: {node: '>=4.0'}
dev: false
/estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
dev: false
/estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
dependencies:
'@types/estree': 1.0.5
dev: false
/esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
dev: false
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: false
/eventsource-parser@1.0.0:
resolution: {integrity: sha512-9jgfSCa3dmEme2ES3mPByGXfgZ87VbP97tng1G2nWwWx6bV2nYxm2AWCrbQjXToSe+yYlqaZNtxffR9IeQr95g==}
engines: {node: '>=14.18'}
dev: false
/execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@ -4652,6 +4919,10 @@ packages:
signal-exit: 4.1.0
dev: false
/form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
dev: false
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
@ -4661,6 +4932,14 @@ packages:
mime-types: 2.1.35
dev: false
/formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
dev: false
/fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
dev: false
@ -4935,6 +5214,12 @@ packages:
engines: {node: '>=10.17.0'}
dev: false
/humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
dependencies:
ms: 2.1.3
dev: false
/iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@ -5048,6 +5333,10 @@ packages:
has-tostringtag: 1.0.2
dev: false
/is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
dev: false
/is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
@ -5141,6 +5430,12 @@ packages:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
dev: false
/is-reference@3.0.2:
resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==}
dependencies:
'@types/estree': 1.0.5
dev: false
/is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'}
@ -5825,6 +6120,16 @@ packages:
hasBin: true
dev: false
/jsondiffpatch@0.6.0:
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
'@types/diff-match-patch': 1.0.36
chalk: 5.3.0
diff-match-patch: 1.0.5
dev: false
/jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@ -5884,6 +6189,10 @@ packages:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: false
/locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
dev: false
/locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -5936,6 +6245,13 @@ packages:
hasBin: true
dev: false
/magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
dev: false
/make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
@ -5954,6 +6270,18 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
dev: false
/mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
dev: false
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: false
@ -6041,6 +6369,12 @@ packages:
thenify-all: 1.6.0
dev: false
/nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: false
/nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -6057,7 +6391,7 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: false
/next-auth@5.0.0-beta.15(next@14.2.0-canary.31)(react@18.2.0):
/next-auth@5.0.0-beta.15(next@14.2.0-canary.39)(react@18.2.0):
resolution: {integrity: sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
@ -6074,7 +6408,7 @@ packages:
optional: true
dependencies:
'@auth/core': 0.28.0
next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
dev: false
@ -6088,8 +6422,8 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/next@14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-aKls3+4raMbu8ex6YDuQFa8U4ajKojXpn8GvdlFtgNUyBHJ7IrVuaFJw6rU9s9OibfgpFnAbsmvKzYQmAAjmlg==}
/next@14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-sTAsUnf7ihBdvN0XwiPKe6kfqxUeEZJaHVOR5RIt2LJ2OnI1mVAp875hjKNxDeOxg2TjpxQCWiEEeKE8IV/tvw==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
@ -6103,7 +6437,7 @@ packages:
sass:
optional: true
dependencies:
'@next/env': 14.2.0-canary.31
'@next/env': 14.2.0-canary.39
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001591
@ -6113,20 +6447,37 @@ packages:
react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(@babel/core@7.23.9)(react@18.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.0-canary.31
'@next/swc-darwin-x64': 14.2.0-canary.31
'@next/swc-linux-arm64-gnu': 14.2.0-canary.31
'@next/swc-linux-arm64-musl': 14.2.0-canary.31
'@next/swc-linux-x64-gnu': 14.2.0-canary.31
'@next/swc-linux-x64-musl': 14.2.0-canary.31
'@next/swc-win32-arm64-msvc': 14.2.0-canary.31
'@next/swc-win32-ia32-msvc': 14.2.0-canary.31
'@next/swc-win32-x64-msvc': 14.2.0-canary.31
'@next/swc-darwin-arm64': 14.2.0-canary.39
'@next/swc-darwin-x64': 14.2.0-canary.39
'@next/swc-linux-arm64-gnu': 14.2.0-canary.39
'@next/swc-linux-arm64-musl': 14.2.0-canary.39
'@next/swc-linux-x64-gnu': 14.2.0-canary.39
'@next/swc-linux-x64-musl': 14.2.0-canary.39
'@next/swc-win32-arm64-msvc': 14.2.0-canary.39
'@next/swc-win32-ia32-msvc': 14.2.0-canary.39
'@next/swc-win32-x64-msvc': 14.2.0-canary.39
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
dev: false
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
dev: false
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false
/node-gyp-build@4.8.0:
resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==}
hasBin: true
@ -6259,6 +6610,23 @@ packages:
mimic-fn: 2.1.0
dev: false
/openai@4.29.2:
resolution: {integrity: sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==}
hasBin: true
dependencies:
'@types/node': 18.19.24
'@types/node-fetch': 2.6.11
abort-controller: 3.0.0
agentkeepalive: 4.5.0
digest-fetch: 1.3.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
web-streams-polyfill: 3.3.3
transitivePeerDependencies:
- encoding
dev: false
/opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
@ -6364,6 +6732,14 @@ packages:
engines: {node: '>=8'}
dev: false
/periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
dependencies:
'@types/estree': 1.0.5
estree-walker: 3.0.3
is-reference: 3.0.2
dev: false
/pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
@ -6858,6 +7234,20 @@ packages:
lru-cache: 6.0.0
dev: false
/seroval-plugins@1.0.5(seroval@1.0.5):
resolution: {integrity: sha512-8+pDC1vOedPXjKG7oz8o+iiHrtF2WswaMQJ7CKFpccvSYfrzmvKY9zOJWCg+881722wIHfwkdnRmiiDm9ym+zQ==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
dependencies:
seroval: 1.0.5
dev: false
/seroval@1.0.5:
resolution: {integrity: sha512-TM+Z11tHHvQVQKeNlOUonOWnsNM+2IBwZ4vwoi4j3zKzIpc5IDw8WPwCfcc8F17wy6cBcJGbZbFOR0UCuTZHQA==}
engines: {node: '>=10'}
dev: false
/server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
dev: false
@ -6932,6 +7322,25 @@ packages:
engines: {node: '>=8'}
dev: false
/solid-js@1.8.15:
resolution: {integrity: sha512-d0QP/efr3UVcwGgWVPveQQ0IHOH6iU7yUhc2piy8arNG8wxKmvUy1kFxyF8owpmfCWGB87usDKMaVnsNYZm+Vw==}
dependencies:
csstype: 3.1.3
seroval: 1.0.5
seroval-plugins: 1.0.5(seroval@1.0.5)
dev: false
/solid-swr-store@0.10.7(solid-js@1.8.15)(swr-store@0.10.6):
resolution: {integrity: sha512-A6d68aJmRP471aWqKKPE2tpgOiR5fH4qXQNfKIec+Vap+MGQm3tvXlT8n0I8UgJSlNAsSAUuw2VTviH2h3Vv5g==}
engines: {node: '>=10'}
peerDependencies:
solid-js: ^1.2
swr-store: ^0.10
dependencies:
solid-js: 1.8.15
swr-store: 0.10.6
dev: false
/sonner@1.4.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-SArYlHbkjqRuLiR0iGY2ZSr09oOrxw081ZZkQPfXrs8aZQLIBOLOdzTYxGJB5yIZ7qL56UEPmrX1YqbODwG0Lw==}
peerDependencies:
@ -6963,6 +7372,15 @@ packages:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: false
/sswr@2.0.0(svelte@4.2.12):
resolution: {integrity: sha512-mV0kkeBHcjcb0M5NqKtKVg/uTIYNlIIniyDfSGrSfxpEdM9C365jK0z55pl9K0xAkNTJi2OAOVFQpgMPUk+V0w==}
peerDependencies:
svelte: ^4.0.0
dependencies:
svelte: 4.2.12
swrev: 4.0.0
dev: false
/stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@ -7150,6 +7568,54 @@ packages:
engines: {node: '>= 0.4'}
dev: false
/svelte@4.2.12:
resolution: {integrity: sha512-d8+wsh5TfPwqVzbm4/HCXC783/KPHV60NvwitJnyTA5lWn1elhXMNWhXGCJ7PwPa8qFUnyJNIyuIRt2mT0WMug==}
engines: {node: '>=16'}
dependencies:
'@ampproject/remapping': 2.2.1
'@jridgewell/sourcemap-codec': 1.4.15
'@jridgewell/trace-mapping': 0.3.22
'@types/estree': 1.0.5
acorn: 8.11.3
aria-query: 5.3.0
axobject-query: 4.0.0
code-red: 1.0.4
css-tree: 2.3.1
estree-walker: 3.0.3
is-reference: 3.0.2
locate-character: 3.0.0
magic-string: 0.30.8
periscopic: 3.1.0
dev: false
/swr-store@0.10.6:
resolution: {integrity: sha512-xPjB1hARSiRaNNlUQvWSVrG5SirCjk2TmaUyzzvk69SZQan9hCJqw/5rG9iL7xElHU784GxRPISClq4488/XVw==}
engines: {node: '>=10'}
dependencies:
dequal: 2.0.3
dev: false
/swr@2.2.0(react@18.2.0):
resolution: {integrity: sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/swrev@4.0.0:
resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==}
dev: false
/swrv@1.0.4(vue@3.4.21):
resolution: {integrity: sha512-zjEkcP8Ywmj+xOJW3lIT65ciY/4AL4e/Or7Gj0MzU3zBJNMdJiT8geVZhINavnlHRMMCcJLHhraLTAiDOTmQ9g==}
peerDependencies:
vue: '>=3.2.26 < 4'
dependencies:
vue: 3.4.21(typescript@5.4.2)
dev: false
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: false
@ -7247,6 +7713,10 @@ packages:
url-parse: 1.5.10
dev: false
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: false
/tr46@3.0.0:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
@ -7461,6 +7931,14 @@ packages:
tslib: 2.6.2
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/utf-8-validate@6.0.3:
resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==}
engines: {node: '>=6.14.2'}
@ -7487,6 +7965,22 @@ packages:
convert-source-map: 2.0.0
dev: false
/vue@3.4.21(typescript@5.4.2):
resolution: {integrity: sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@vue/compiler-dom': 3.4.21
'@vue/compiler-sfc': 3.4.21
'@vue/runtime-dom': 3.4.21
'@vue/server-renderer': 3.4.21(vue@3.4.21)
'@vue/shared': 3.4.21
typescript: 5.4.2
dev: false
/w3c-xmlserializer@4.0.0:
resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
engines: {node: '>=14'}
@ -7500,6 +7994,20 @@ packages:
makeerror: 1.0.12
dev: false
/web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
dev: false
/web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
dev: false
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false
/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
@ -7548,6 +8056,13 @@ packages:
webidl-conversions: 7.0.0
dev: false
/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: false
/which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
dependencies:
@ -7730,3 +8245,15 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
dev: false
/zod-to-json-schema@3.22.4(zod@3.22.4):
resolution: {integrity: sha512-2Ed5dJ+n/O3cU383xSY28cuVi0BCQhF8nYqWU5paEpl7fVdqdAmiLdqLyfblbNdfOFwFfi/mqU4O1pwc60iBhQ==}
peerDependencies:
zod: ^3.22.4
dependencies:
zod: 3.22.4
dev: false
/zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: false

View File

@ -2,6 +2,7 @@ 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';
export default async function PhotoEditPage({
params: { photoId },
@ -14,7 +15,13 @@ export default async function PhotoEditPage({
const uniqueTags = await getUniqueTagsCached();
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
return (
<PhotoEditPageClient {...{ photo, uniqueTags }} />
<PhotoEditPageClient {...{
photo,
uniqueTags,
hasAiTextGeneration,
}} />
);
};

View File

@ -3,6 +3,10 @@ import { extractExifDataFromBlobPath } 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,
} from '@/site/config';
interface Params {
params: { uploadPath: string }
@ -14,11 +18,21 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
photoFormExif,
} = await extractExifDataFromBlobPath(uploadPath);
const uniqueTags = await getUniqueTagsCached();
if (!photoFormExif) { redirect(PATH_ADMIN); }
const uniqueTags = await getUniqueTagsCached();
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
const textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS;
return (
<UploadPageClient {...{ blobId, photoFormExif, uniqueTags }} />
<UploadPageClient {...{
blobId,
photoFormExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
}} />
);
};

View File

@ -44,7 +44,7 @@ export const {
},
});
export const safelyRunServerAdminAction = async <T>(
export const safelyRunAdminServerAction = async <T>(
callback: () => T,
): Promise<T> => {
const session = await auth();

View File

@ -10,6 +10,7 @@ function AdminChildPage({
backPath,
backLabel,
breadcrumb,
breadcrumbEllipsis,
accessory,
isLoading,
children,
@ -17,6 +18,7 @@ function AdminChildPage({
backPath?: string
backLabel?: string
breadcrumb?: ReactNode
breadcrumbEllipsis?: boolean
accessory?: ReactNode
isLoading?: boolean
children: ReactNode,
@ -27,12 +29,14 @@ function AdminChildPage({
<div className="space-y-6">
{(backPath || breadcrumb || accessory) &&
<div className={clsx(
'flex flex-wrap items-center gap-x-2 gap-y-3',
'flex items-center gap-x-2 gap-y-3',
!breadcrumbEllipsis && 'flex-wrap',
'min-h-[2.25rem]', // min-h-9 equivalent
)}>
<div className={clsx(
'flex flex-wrap items-center gap-x-1.5 sm:gap-x-3 gap-y-1',
'flex items-center gap-x-1.5 sm:gap-x-3 gap-y-1',
'flex-grow',
breadcrumbEllipsis ? 'min-w-0' : 'flex-wrap',
)}>
{backPath &&
<Link
@ -40,12 +44,19 @@ function AdminChildPage({
className="flex gap-1.5 items-center"
>
<FiArrowLeft size={16} />
{backLabel || 'Back'}
<span className="hidden xs:inline-block">
{backLabel || 'Back'}
</span>
</Link>}
{breadcrumb &&
<>
<span>/</span>
<Badge dimContent={isLoading}>
<Badge
dimContent={isLoading}
className={clsx(
breadcrumbEllipsis && 'text-ellipsis truncate',
)}
>
{breadcrumb}
</Badge>
</>}

View File

@ -8,6 +8,7 @@ export default function Badge({
highContrast,
uppercase,
interactive,
className,
}: {
children: React.ReactNode
className?: string
@ -16,6 +17,7 @@ export default function Badge({
highContrast?: boolean
uppercase?: boolean
interactive?: boolean
className?: string
}) {
const stylesForType = () => {
switch (type) {
@ -47,6 +49,7 @@ export default function Badge({
'leading-none',
stylesForType(),
uppercase && 'uppercase tracking-wider',
className,
)}>
<span className={clsx(dimContent && 'opacity-50')}>
{children}

View File

@ -6,7 +6,9 @@ const RETRY_DELAY = 2000;
export default function CanvasBlurCapture({
imageUrl,
onLoad,
onCapture,
onError,
width,
height,
hidden = true,
@ -15,7 +17,9 @@ export default function CanvasBlurCapture({
quality = 0.9,
}: {
imageUrl: string
onCapture: (blurData: string) => void
onLoad?: (imageData: string) => void
onCapture: (imageData: string) => void
onError?: (error: string) => void
width: number
height: number
hidden?: boolean
@ -44,7 +48,18 @@ export default function CanvasBlurCapture({
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)`;
@ -56,17 +71,21 @@ export default function CanvasBlurCapture({
width * refImage.current.height / refImage.current.width +
edgeCompensation * 2,
);
refTimeouts.current.forEach(clearTimeout);
onCapture(canvas.toDataURL('image/jpeg', quality));
onError?.('');
refTimeouts.current.forEach(clearTimeout);
refShouldCapture.current = false;
} else {
console.error('Cannot get 2d context');
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');
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));
}
@ -92,6 +111,8 @@ export default function CanvasBlurCapture({
}, [
imageUrl,
onCapture,
onLoad,
onError,
width,
height,
edgeCompensation,

View File

@ -1,30 +1,36 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx/lite';
import ExperimentalBadge from './ExperimentalBadge';
import Badge from './Badge';
export default function Checklist({
title,
icon,
optional,
experimental,
children,
}: {
title: string
icon?: ReactNode
optional?: boolean
experimental?: boolean
children: ReactNode
}) {
return (
<div>
<div className={clsx(
'flex items-center gap-3',
'inline-flex items-center',
'text-gray-600 dark:text-gray-300',
'pl-[18px] mb-3 text-lg',
)}>
{icon}
<div className="flex gap-1.5">
<div>{title}</div>
<span className="w-7 shrink-0">{icon}</span>
<span className="inline-flex flex-wrap items-center gap-y-1 gap-x-1.5">
{title}
{optional &&
<div className="text-dim">(Optional)</div>}
</div>
<Badge type="small">Optional</Badge>}
{experimental &&
<ExperimentalBadge />}
</span>
</div>
<div className={clsx(
'bg-white dark:bg-black',

View File

@ -1,6 +1,7 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx/lite';
import StatusIcon from './StatusIcon';
import ExperimentalBadge from './ExperimentalBadge';
export default function ChecklistRow({
title,
@ -33,15 +34,7 @@ export default function ChecklistRow({
)}>
{title}
{experimental &&
<span className={clsx(
'text-[9px] font-medium uppercase tracking-wide leading-none',
'px-[3px] py-[2px] rounded-[0.2rem] translate-y-[0.5px]',
'text-pink-500 dark:text-white',
'bg-pink-50 dark:bg-pink-600',
'border border-pink-200/50 dark:border-pink-600',
)}>
Experimental
</span>}
<ExperimentalBadge className="translate-y-[0.5px]" />}
</div>
<div>
{children}

View File

@ -28,6 +28,7 @@ export type CommandKSection = {
accessory?: ReactNode
items: {
label: string
keywords?: string[]
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
@ -156,8 +157,13 @@ export default function CommandKClient({
open={isOpen}
onOpenChange={setIsOpen}
label="Global Command Menu"
filter={(value, search) =>
value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}
filter={(value, search, keywords) => {
const searchFormatted = search.trim().toLocaleLowerCase();
return (
value.toLocaleLowerCase().includes(searchFormatted) ||
keywords?.includes(searchFormatted)
) ? 1 : 0 ;
}}
loop
>
<Modal
@ -222,16 +228,18 @@ export default function CommandKClient({
)}
>
{items.map(({
accessory,
label,
keywords,
annotation,
annotationAria,
accessory,
path,
action,
}) =>
<Command.Item
key={`${heading} ${label}`}
value={`${heading} ${label}`}
keywords={keywords}
className={clsx(
'px-2',
accessory ? 'py-2' : 'py-1',

View File

@ -0,0 +1,20 @@
import { clsx } from 'clsx/lite';
import Badge from './Badge';
export default function ExperimentalBadge({
className,
}: {
className?: string
}) {
return (
<Badge
type="small"
className={clsx(
'text-pink-500 dark:text-white',
'bg-pink-100 dark:bg-pink-600',
className,
)}>
Experimental
</Badge>
);
}

View File

@ -24,6 +24,7 @@ export default function FieldSetWithStatus({
capitalize,
type = 'text',
inputRef,
accessory,
}: {
id: string
label: string
@ -41,6 +42,7 @@ export default function FieldSetWithStatus({
capitalize?: boolean
type?: FieldSetType
inputRef?: LegacyRef<HTMLInputElement>
accessory?: React.ReactNode
}) {
const { pending } = useFormStatus();
@ -68,58 +70,76 @@ export default function FieldSetWithStatus({
<Spinner />
</span>}
</label>
{selectOptions
? <select
id={id}
name={id}
value={value}
onChange={e => onChange?.(e.target.value)}
className={clsx(
'w-full',
clsx(Boolean(error) && 'error'),
// Use special class because `select` can't be readonly
readOnly || pending && 'disabled-select',
)}
>
{selectOptionsDefaultLabel &&
<option value="">{selectOptionsDefaultLabel}</option>}
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
<option
key={optionValue}
value={optionValue}
>
{optionLabel}
</option>)}
</select>
: tagOptions
? <TagInput
<div className="flex gap-2">
{selectOptions
? <select
id={id}
name={id}
value={value}
options={tagOptions}
onChange={onChange}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending}
/>
: <input
ref={inputRef}
id={id}
name={id}
value={value}
checked={type === 'checkbox' ? value === 'true' : undefined}
placeholder={placeholder}
onChange={e => onChange?.(type === 'checkbox'
? e.target.value === 'true' ? 'false' : 'true'
: e.target.value)}
type={type}
autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending}
onChange={e => onChange?.(e.target.value)}
className={clsx(
type === 'text' && 'w-full',
Boolean(error) && 'error',
'w-full',
clsx(Boolean(error) && 'error'),
// Use special class because `select` can't be readonly
readOnly || pending && 'disabled-select',
)}
/>}
>
{selectOptionsDefaultLabel &&
<option value="">{selectOptionsDefaultLabel}</option>}
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
<option
key={optionValue}
value={optionValue}
>
{optionLabel}
</option>)}
</select>
: tagOptions
? <TagInput
id={id}
name={id}
value={value}
options={tagOptions}
onChange={onChange}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading}
/>
: type === 'textarea'
? <textarea
id={id}
name={id}
value={value}
placeholder={placeholder}
onChange={e => onChange?.(e.target.value)}
readOnly={readOnly || pending || loading}
className={clsx(
'w-full h-24 resize-none',
Boolean(error) && 'error',
)}
/>
: <input
ref={inputRef}
id={id}
name={id}
value={value}
checked={type === 'checkbox' ? value === 'true' : undefined}
placeholder={placeholder}
onChange={e => onChange?.(type === 'checkbox'
? e.target.value === 'true' ? 'false' : 'true'
: e.target.value)}
type={type}
autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending || loading}
className={clsx(
type === 'text' && 'w-full',
Boolean(error) && 'error',
)}
/>}
<div>
{accessory}
</div>
</div>
</div>
);
};

View File

@ -4,21 +4,27 @@ import AdminChildPage from '@/components/AdminChildPage';
import { Photo } from '.';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { PhotoFormData, convertPhotoToFormData } from './form';
import {
PhotoFormData,
convertPhotoToFormData,
} from './form';
import PhotoForm from './form/PhotoForm';
import { useFormState } from 'react-dom';
import { areSimpleObjectsEqual } from '@/utility/object';
import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions';
import { TagsWithMeta } from '@/tag';
import { useState } from 'react';
import AiButton from './ai/AiButton';
import usePhotoFormParent from './form/usePhotoFormParent';
export default function PhotoEditPageClient({
photo,
uniqueTags,
hasAiTextGeneration,
}: {
photo: Photo
uniqueTags?: TagsWithMeta
uniqueTags: TagsWithMeta
hasAiTextGeneration: boolean
}) {
const seedExifData = { url: photo.url };
@ -27,14 +33,23 @@ export default function PhotoEditPageClient({
seedExifData,
);
const [pending, setIsPending] = useState(false);
const [updatedTitle, setUpdatedTitle] = useState('');
const hasExifDataBeenFound = !areSimpleObjectsEqual(
updatedExifData,
seedExifData,
);
const photoForm = convertPhotoToFormData(photo);
const {
pending,
setIsPending,
updatedTitle,
setUpdatedTitle,
hasTextContent,
setHasTextContent,
aiContent,
} = usePhotoFormParent({ photoForm });
return (
<AdminChildPage
backPath={PATH_ADMIN_PHOTOS}
@ -42,27 +57,34 @@ export default function PhotoEditPageClient({
breadcrumb={pending && updatedTitle
? updatedTitle
: photo.title || photo.id}
breadcrumbEllipsis
accessory={
<form action={action}>
<input name="photoUrl" value={photo.url} hidden readOnly />
<SubmitButtonWithStatus
icon={<IconGrSync
className="translate-y-[-1px] sm:mr-[4px]"
/>}
>
EXIF
</SubmitButtonWithStatus>
</form>}
<div className="flex gap-2">
{hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
<form action={action}>
<input name="photoUrl" value={photo.url} hidden readOnly />
<SubmitButtonWithStatus
icon={<IconGrSync
className="translate-y-[-1px] sm:mr-[4px]"
/>}
>
EXIF
</SubmitButtonWithStatus>
</form>
</div>}
isLoading={pending}
>
<PhotoForm
type="edit"
initialPhotoForm={convertPhotoToFormData(photo)}
initialPhotoForm={photoForm}
updatedExifData={hasExifDataBeenFound
? updatedExifData
: undefined}
uniqueTags={uniqueTags}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}
onTextContentChange={setHasTextContent}
onFormStatusChange={setIsPending}
/>
</AdminChildPage>

View File

@ -1,5 +1,6 @@
import {
Photo,
altTextForPhoto,
shouldShowCameraDataForPhoto,
shouldShowExifDataForPhoto,
titleForPhoto,
@ -61,7 +62,7 @@ export default function PhotoLarge({
>
<ImageLarge
className="w-full"
alt={titleForPhoto(photo)}
alt={altTextForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}

View File

@ -1,4 +1,4 @@
import { Photo, titleForPhoto } from '.';
import { Photo, altTextForPhoto } from '.';
import ImageSmall from '@/components/ImageSmall';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
@ -38,7 +38,7 @@ export default function PhotoSmall({
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
className="w-full"
alt={titleForPhoto(photo)}
alt={altTextForPhoto(photo)}
priority={priority}
/>
</Link>

View File

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

View File

@ -4,20 +4,33 @@ import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_UPLOADS } from '@/site/paths';
import { PhotoFormData } from './form';
import PhotoForm from './form/PhotoForm';
import { useState } from 'react';
import { TagsWithMeta } from '@/tag';
import usePhotoFormParent from './form/usePhotoFormParent';
import AiButton from './ai/AiButton';
import { AiAutoGeneratedField } from './ai';
export default function UploadPageClient({
blobId,
photoFormExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
}: {
blobId?: string
photoFormExif: Partial<PhotoFormData>
uniqueTags: TagsWithMeta
hasAiTextGeneration?: boolean
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
}) {
const [pending, setIsPending] = useState(false);
const [updatedTitle, setUpdatedTitle] = useState('');
const {
pending,
setIsPending,
updatedTitle,
setUpdatedTitle,
hasTextContent,
setHasTextContent,
aiContent,
} = usePhotoFormParent({ textFieldsToAutoGenerate });
return (
<AdminChildPage
@ -26,12 +39,17 @@ export default function UploadPageClient({
breadcrumb={pending && updatedTitle
? updatedTitle
: blobId}
breadcrumbEllipsis
accessory={hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
isLoading={pending}
>
<PhotoForm
initialPhotoForm={photoFormExif}
uniqueTags={uniqueTags}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}
onTextContentChange={setHasTextContent}
onFormStatusChange={setIsPending}
/>
</AdminChildPage>

View File

@ -38,10 +38,12 @@ import { TbPhoto } from 'react-icons/tb';
import PhotoTiny from './PhotoTiny';
import { formatDate } from '@/utility/date';
import { convertPhotoToPhotoDbInsert, titleForPhoto } from '.';
import { safelyRunServerAdminAction } from '@/auth';
import { safelyRunAdminServerAction } from '@/auth';
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/services/openai';
export async function createPhotoAction(formData: FormData) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData, true);
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
@ -57,7 +59,7 @@ export async function createPhotoAction(formData: FormData) {
}
export async function updatePhotoAction(formData: FormData) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData);
await sqlUpdatePhoto(photo);
@ -72,7 +74,7 @@ export async function toggleFavoritePhotoAction(
photoId: string,
shouldRedirect?: boolean,
) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const photo = await getPhoto(photoId);
if (photo) {
const { tags } = photo;
@ -93,7 +95,7 @@ export async function deletePhotoAction(
photoUrl: string,
shouldRedirect?: boolean,
) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
@ -103,7 +105,7 @@ export async function deletePhotoAction(
};
export async function deletePhotoFormAction(formData: FormData) {
return safelyRunServerAdminAction(async () =>
return safelyRunAdminServerAction(async () =>
deletePhotoAction(
formData.get('id') as string,
formData.get('url') as string,
@ -112,7 +114,7 @@ export async function deletePhotoFormAction(formData: FormData) {
};
export async function deletePhotoTagGloballyAction(formData: FormData) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const tag = formData.get('tag') as string;
await sqlDeletePhotoTagGlobally(tag);
@ -123,7 +125,7 @@ export async function deletePhotoTagGloballyAction(formData: FormData) {
}
export async function renamePhotoTagGloballyAction(formData: FormData) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const tag = formData.get('tag') as string;
const updatedTag = formData.get('updatedTag') as string;
@ -137,7 +139,7 @@ export async function renamePhotoTagGloballyAction(formData: FormData) {
}
export async function deleteBlobPhotoAction(formData: FormData) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
await deleteStorageUrl(formData.get('url') as string);
revalidateAdminPaths();
@ -151,7 +153,7 @@ export async function deleteBlobPhotoAction(formData: FormData) {
export async function getExifDataAction(
photoFormPrevious: Partial<PhotoFormData>,
): Promise<Partial<PhotoFormData>> {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const { url } = photoFormPrevious;
if (url) {
const { photoFormExif } = await extractExifDataFromBlobPath(url);
@ -164,7 +166,7 @@ export async function getExifDataAction(
}
export async function syncPhotoExifDataAction(formData: FormData) {
return safelyRunServerAdminAction(async () => {
return safelyRunAdminServerAction(async () => {
const photoId = formData.get('id') as string;
if (photoId) {
const photo = await getPhoto(photoId);
@ -184,11 +186,19 @@ export async function syncPhotoExifDataAction(formData: FormData) {
}
export async function syncCacheAction() {
return safelyRunServerAdminAction(revalidateAllKeysAndPaths);
return safelyRunAdminServerAction(revalidateAllKeysAndPaths);
}
export async function streamAiImageQueryAction(
imageBase64: string,
query: AiImageQuery,
) {
return safelyRunAdminServerAction(async () =>
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
}
export async function getPhotoItemsAction(query: string) {
const photos = (await getPhotos({ title: query, limit: 10 }))
const photos = (await getPhotos({ query, limit: 10 }))
.filter(({ title }) => Boolean(title));
return photos.length > 0
? [{

63
src/photo/ai/AiButton.tsx Normal file
View File

@ -0,0 +1,63 @@
import Spinner from '@/components/Spinner';
import { AiContent } from './useAiImageQueries';
import { HiSparkles } from 'react-icons/hi';
import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.';
import { useMemo } from 'react';
import { clsx } from 'clsx/lite';
export default function AiButton({
aiContent,
requestFields = ALL_AI_AUTO_GENERATED_FIELDS,
shouldConfirm,
className,
}: {
aiContent: AiContent
requestFields?: AiAutoGeneratedField[]
shouldConfirm?: boolean
className?: string
}) {
const isLoading = useMemo(() =>
(requestFields ?? []).map(field => {
switch (field) {
case 'title':
return aiContent.isLoadingTitle;
case 'caption':
return aiContent.isLoadingCaption;
case 'tags':
return aiContent.isLoadingTags;
case 'semantic':
return aiContent.isLoadingSemantic;
default:
return false;
}
}).some(Boolean)
, [
requestFields,
aiContent.isLoadingCaption,
aiContent.isLoadingSemantic,
aiContent.isLoadingTags,
aiContent.isLoadingTitle,
]);
return (
<button
className={clsx(
'flex min-w-[3.25rem] min-h-9 justify-center',
className,
)}
onClick={e => {
if (
!shouldConfirm ||
confirm('Are you sure you want to overwrite existing content?')
) {
aiContent.request(requestFields);
} else {
e.preventDefault();
}
}}
disabled={!aiContent.isReady || isLoading}
>
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
</button>
);
}

65
src/photo/ai/index.ts Normal file
View File

@ -0,0 +1,65 @@
/* eslint-disable max-len */
export type AiAutoGeneratedField =
'title' |
'caption' |
'tags' |
'semantic'
export const ALL_AI_AUTO_GENERATED_FIELDS: AiAutoGeneratedField[] = [
'title',
'caption',
'tags',
'semantic',
];
export const parseAiAutoGeneratedFieldsText = (
text = 'all',
): AiAutoGeneratedField[] => {
const textFormatted = text.trim().toLocaleLowerCase();
if (textFormatted === 'none') {
return [];
} else if (textFormatted === 'all') {
return ALL_AI_AUTO_GENERATED_FIELDS;
} else {
const fields = textFormatted
.toLocaleLowerCase()
.split(',')
.map(field => field.trim())
.filter(field => ALL_AI_AUTO_GENERATED_FIELDS
.includes(field as AiAutoGeneratedField));
return fields as AiAutoGeneratedField[];
}
};
export type AiImageQuery =
'title' |
'caption' |
'title-and-caption' |
'tags' |
'description-small' |
'description' |
'description-large' |
'description-semantic';
export const AI_IMAGE_QUERIES: Record<AiImageQuery, string> = {
'title': 'Write a short title for this image in 3 words or less',
'caption': 'Write a pithy caption for this image in 6 words or less and no punctuation',
'title-and-caption': 'Write a short title and pithy caption of 8 words or less for this image, using the format Title: "title" Caption: "caption"',
'tags': 'Describe this image three or less comma-separated keywords with no adjective or adverbs',
'description-small': 'Describe this image succinctly without the initial text "This image shows" or "This is a picture of"',
'description': 'Describe this image',
'description-large': 'Describe this image in detail',
'description-semantic': 'List up to 5 things in this image without description as a comma-separated list',
};
export const parseTitleAndCaption = (text: string) => {
const matches = text.includes('Title')
? text.match(/^[`'"]*Title: ["']*(.*?)["']*[ ]*Caption: ["']*(.*?)\.*["']*[`'"]*$/)
: text.match(/^(.*?): (.*?)$/);
return {
title: matches?.[1] ?? '',
caption: matches?.[2] ?? '',
};
};

View File

@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import useAiImageQuery from './useAiImageQuery';
import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery';
import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.';
export type AiContent = ReturnType<typeof useAiImageQueries>;
export default function useAiImageQueries(
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
) {
const [imageData, setImageData] = useState<string>();
const isReady = Boolean(imageData);
const [
requestTitleCaption,
_title,
_caption,
_isLoadingTitle,
_isLoadingCaption,
resetTitle,
resetCaption,
] = useTitleCaptionAiImageQuery(imageData);
const [
requestTitle,
titleSolo,
isLoadingTitleSolo,
resetTitleSolo,
] = useAiImageQuery(imageData, 'title');
const [
requestCaption,
captionSolo,
isLoadingCaptionSolo,
resetCaptionSolo,
] = useAiImageQuery(imageData, 'caption');
const [
requestTags,
tags,
isLoadingTags,
] = useAiImageQuery(imageData, 'tags');
const [
requestSemantic,
semanticDescription,
isLoadingSemantic,
] = useAiImageQuery(imageData, 'description-small');
const title = _title || titleSolo;
const caption = _caption || captionSolo;
const isLoadingTitle = _isLoadingTitle || isLoadingTitleSolo;
const isLoadingCaption = _isLoadingCaption || isLoadingCaptionSolo;
const isLoading =
isLoadingTitle ||
isLoadingCaption ||
isLoadingTags ||
isLoadingSemantic;
const hasRunAllQueriesOnce = useRef(false);
const request = useCallback(async (
fields = ALL_AI_AUTO_GENERATED_FIELDS,
) => {
if (process.env.NODE_ENV === 'development') {
console.log('RUNNING AI QUERIES', fields);
}
hasRunAllQueriesOnce.current = true;
if (fields.includes('title') && fields.includes('caption')) {
// Unmask individual title + caption
resetTitleSolo();
resetCaptionSolo();
requestTitleCaption();
} else {
if (fields.includes('title')) {
// Unmask combined title
resetTitle();
resetTitleSolo();
requestTitle();
}
if (fields.includes('caption')) {
// Unmask combined caption
resetCaption();
resetCaptionSolo();
requestCaption();
}
}
if (fields.includes('tags')) { requestTags(); }
if (fields.includes('semantic')) { requestSemantic(); }
}, [
requestTitleCaption,
requestTitle,
requestCaption,
requestTags,
requestSemantic,
resetTitle,
resetTitleSolo,
resetCaption,
resetCaptionSolo,
]);
useEffect(() => {
if (imageData && !hasRunAllQueriesOnce.current) {
if (textFieldsToAutoGenerate.length > 0) {
request(textFieldsToAutoGenerate);
}
}
}, [textFieldsToAutoGenerate, imageData, request]);
return {
request,
title,
caption,
tags,
semanticDescription,
isReady,
isLoading,
isLoadingTitle,
isLoadingCaption,
isLoadingTags,
isLoadingSemantic,
setImageData,
};
}

View File

@ -0,0 +1,53 @@
import { useCallback, useState } from 'react';
import { streamAiImageQueryAction } from '../actions';
import { readStreamableValue } from 'ai/rsc';
import { AiImageQuery } from '.';
export default function useAiImageQuery(
imageBase64: string | undefined,
query: AiImageQuery,
) {
const [text, setText] = useState('');
const [error, setError] = useState<any>();
const [isLoading, setIsLoading] = useState(false);
const request = useCallback(async () => {
if (imageBase64) {
setIsLoading(true);
setText('');
try {
const textStream = await streamAiImageQueryAction(
imageBase64,
query,
);
for await (const text of readStreamableValue(textStream)) {
setText((text ?? '')
.replaceAll('\n', ' ')
.replaceAll('"', '')
.replace(/\.$/, ''));
}
setIsLoading(false);
} catch (e) {
setError(e);
setIsLoading(false);
}
}
}, [imageBase64, query]);
const reset = useCallback(() => {
setText('');
setError(undefined);
setIsLoading(false);
}, []);
// Withhold streaming text if it's a null response
const isTextError = text.toLocaleLowerCase().startsWith('sorry');
return [
request,
isTextError ? '' : text,
isLoading,
reset,
error,
] as const;
};

View File

@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react';
import useAiImageQuery from './useAiImageQuery';
import { parseTitleAndCaption } from '.';
export default function useTitleCaptionAiImageQuery(
imageBase64: string | undefined,
) {
const [
request,
text,
isLoading,
_reset,
error,
] = useAiImageQuery(imageBase64, 'title-and-caption');
const [title, setTitle] = useState('');
const [caption, setCaption] = useState('');
useEffect(() => {
const { title, caption } = parseTitleAndCaption(text);
setTitle(title);
setCaption(caption);
}, [text]);
const resetTitle = useCallback(() => setTitle(''), []);
const resetCaption = useCallback(() => setCaption(''), []);
const isLoadingTitle = isLoading && !caption;
const isLoadingCaption = isLoading;
return [
request,
title,
caption,
isLoadingTitle,
isLoadingCaption,
resetTitle,
resetCaption,
error,
] as const;
}

View File

@ -5,6 +5,7 @@ import {
FORM_METADATA_ENTRIES,
PhotoFormData,
convertFormKeysToLabels,
formHasTextContent,
getFormErrors,
isFormValid,
} from '.';
@ -25,6 +26,8 @@ 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';
const THUMBNAIL_SIZE = 300;
@ -33,22 +36,29 @@ export default function PhotoForm({
updatedExifData,
type = 'create',
uniqueTags,
aiContent,
debugBlur,
onTitleChange,
onTextContentChange,
onFormStatusChange,
}: {
initialPhotoForm: Partial<PhotoFormData>
updatedExifData?: Partial<PhotoFormData>
type?: 'create' | 'edit'
uniqueTags?: TagsWithMeta
aiContent?: AiContent
setImageData?: (imageData: string) => void
debugBlur?: boolean
onTitleChange?: (updatedTitle: string) => void
onTextContentChange?: (hasContent: boolean) => void,
onFormStatusChange?: (pending: boolean) => void
}) {
const [formData, setFormData] =
useState<Partial<PhotoFormData>>(initialPhotoForm);
const [formErrors, setFormErrors] =
useState(getFormErrors(initialPhotoForm));
const [blurError, setBlurError] =
useState<string>();
// Update form when EXIF data
// is refreshed by parent
@ -114,8 +124,89 @@ export default function PhotoForm({
}
}, []);
useEffect(() =>
setFormData(data => aiContent?.title
? { ...data, title: aiContent?.title }
: data),
[aiContent?.title]);
useEffect(() =>
setFormData(data => aiContent?.caption
? { ...data, caption: aiContent?.caption }
: data),
[aiContent?.caption]);
useEffect(() =>
setFormData(data => aiContent?.tags
? { ...data, tags: aiContent?.tags }
: data),
[aiContent?.tags]);
useEffect(() =>
setFormData(data => aiContent?.semanticDescription
? { ...data, semanticDescription: aiContent?.semanticDescription }
: data),
[aiContent?.semanticDescription]);
useEffect(() => {
onTextContentChange?.(formHasTextContent(formData));
}, [onTextContentChange, formData]);
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
switch (key) {
case 'title':
return aiContent?.isLoadingTitle;
case 'caption':
return aiContent?.isLoadingCaption;
case 'tags':
return aiContent?.isLoadingTags;
case 'semanticDescription':
return aiContent?.isLoadingSemantic;
default:
return false;
}
};
const aiButtonForField = (key: keyof PhotoFormData) => {
if (aiContent) {
switch (key) {
case 'title':
return <AiButton
aiContent={aiContent}
requestFields={['title']}
shouldConfirm={Boolean(formData.title)}
className="h-full"
/>;
case 'caption':
return <AiButton
aiContent={aiContent}
requestFields={['caption']}
shouldConfirm={Boolean(formData.caption)}
className="h-full"
/>;
case 'tags':
return <AiButton
aiContent={aiContent}
requestFields={['tags']}
shouldConfirm={Boolean(formData.tags)}
className="h-full"
/>;
case 'semanticDescription':
return <AiButton
aiContent={aiContent}
requestFields={['semantic']}
shouldConfirm={Boolean(formData.semanticDescription)}
/>;
}
}
};
return (
<div className="space-y-8 max-w-[38rem]">
{debugBlur && blurError &&
<div className="border error text-error rounded-md px-2 py-1">
{blurError}
</div>}
<div className="flex gap-2">
<ImageBlurFallback
alt="Upload"
@ -132,7 +223,9 @@ export default function PhotoForm({
imageUrl={url}
width={width}
height={height}
onLoad={aiContent?.setImageData}
onCapture={updateBlurData}
onError={setBlurError}
/>
{debugBlur && formData.blurData &&
<img
@ -157,7 +250,8 @@ export default function PhotoForm({
value: tag,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count, 'tagged'),
}))
})),
aiContent !== undefined,
)
.map(([key, {
label,
@ -187,7 +281,8 @@ export default function PhotoForm({
error={formErrors[key]}
value={formData[key] ?? ''}
onChange={value => {
setFormData({ ...formData, [key]: value });
const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated);
if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) });
} else if (validateStringMaxLength !== undefined) {
@ -211,8 +306,11 @@ export default function PhotoForm({
placeholder={loadingMessage && !formData[key]
? loadingMessage
: undefined}
loading={loadingMessage && !formData[key] ? true : false}
loading={
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)}
type={type}
accessory={aiButtonForField(key)}
/>)}
<div className="flex gap-3">
<Link
@ -222,7 +320,7 @@ export default function PhotoForm({
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid(formData)}
disabled={!isFormValid(formData) || aiContent?.isLoading}
onFormStatusChange={onFormStatusChange}
>
{type === 'create' ? 'Create' : 'Update'}

View File

@ -13,7 +13,10 @@ import {
MAKE_FUJIFILM,
} from '@/vendors/fujifilm';
import { FilmSimulation } from '@/simulation';
import { BLUR_ENABLED, GEO_PRIVACY_ENABLED } from '@/site/config';
import {
BLUR_ENABLED,
GEO_PRIVACY_ENABLED,
} from '@/site/config';
import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag';
type VirtualFields = 'favorite';
@ -24,7 +27,8 @@ export type FieldSetType =
'text' |
'email' |
'password' |
'checkbox';
'checkbox' |
'textarea';
export type AnnotatedTag = {
value: string,
@ -55,7 +59,8 @@ const STRING_MAX_LENGTH_SHORT = 255;
const STRING_MAX_LENGTH_LONG = 1000;
const FORM_METADATA = (
tagOptions?: AnnotatedTag[]
tagOptions?: AnnotatedTag[],
aiTextGeneration?: boolean,
): Record<keyof PhotoFormData, FormMeta> => ({
title: {
label: 'title',
@ -66,13 +71,8 @@ const FORM_METADATA = (
label: 'caption',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
shouldHide: ({ title, caption }) => !title && !caption,
},
semanticDescription: {
label: 'semantic description',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
hide: true,
shouldHide: ({ title, caption }) =>
!aiTextGeneration && (!title && !caption),
},
tags: {
label: 'tags',
@ -81,6 +81,13 @@ const FORM_METADATA = (
? `'${TAG_FAVS}' is a reserved tag`
: undefined,
},
semanticDescription: {
type: 'textarea',
label: 'semantic description (not visible)',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
hide: !aiTextGeneration,
},
id: { label: 'id', readOnly: true, hideIfEmpty: true },
blurData: {
label: 'blur data',
@ -144,6 +151,14 @@ export const isFormValid = (formData: Partial<PhotoFormData>) =>
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
);
export const formHasTextContent = ({
title,
caption,
tags,
semanticDescription,
}: Partial<PhotoFormData>) =>
Boolean(title || caption || tags || semanticDescription);
// CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = (

View File

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

View File

@ -176,6 +176,9 @@ export const translatePhotoId = (id: string) =>
export const titleForPhoto = (photo: Photo) =>
photo.title || 'Untitled';
export const altTextForPhoto = (photo: Photo) =>
photo.semanticDescription || titleForPhoto(photo);
export const photoLabelForCount = (count: number) =>
count === 1 ? 'Photo' : 'Photos';
@ -255,3 +258,9 @@ export const shouldShowCameraDataForPhoto = (photo: Photo) =>
export const shouldShowExifDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA && photoHasExifData(photo);
export const getKeywordsForPhoto = (photo: Photo) =>
(photo.caption ?? '').split(' ')
.concat((photo.semanticDescription ?? '').split(' '))
.filter(Boolean)
.map(keyword => keyword.toLocaleLowerCase());

76
src/services/openai.ts Normal file
View File

@ -0,0 +1,76 @@
'use server';
import OpenAI from 'openai';
import { createStreamableValue, render } from 'ai/rsc';
import { kv } from '@vercel/kv';
import { Ratelimit } from '@upstash/ratelimit';
import { AI_TEXT_GENERATION_ENABLED, HAS_VERCEL_KV } from '@/site/config';
import { safelyRunAdminServerAction } from '@/auth';
const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
const RATE_LIMIT_MAX_QUERIES_PER_HOUR = 100;
const provider = AI_TEXT_GENERATION_ENABLED
? new OpenAI({ apiKey: process.env.OPENAI_SECRET_KEY })
: undefined;
// Allows 100 requests per hour
const ratelimit = HAS_VERCEL_KV
? new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(RATE_LIMIT_MAX_QUERIES_PER_HOUR, '1h'),
})
: undefined;
export const streamOpenAiImageQuery = async (
imageBase64: string,
query: string,
) => {
return safelyRunAdminServerAction(async () => {
if (ratelimit) {
let success = false;
try {
success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success;
} catch (e: any) {
console.error('Failed to rate limit OpenAI', e);
throw new Error('Failed to rate limit OpenAI');
}
if (!success) {
console.error('OpenAI rate limit exceeded');
throw new Error('OpenAI rate limit exceeded');
}
}
const stream = createStreamableValue('');
if (provider) {
render({
provider,
model: 'gpt-4-vision-preview',
messages: [{
'role': 'user',
'content': [
{
'type': 'text',
'text': query,
}, {
'type': 'image_url',
'image_url': {
'url': imageBase64,
},
},
],
}],
text: ({ content, done }): any => {
if (done) {
stream.done(content);
} else {
stream.update(content);
}
},
});
}
return stream.value;
});
};

View File

@ -286,7 +286,7 @@ export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority'
limit?: number
offset?: number
title?: string
query?: string
tag?: string
camera?: Camera
simulation?: FilmSimulation
@ -345,7 +345,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
title,
query,
tag,
camera,
simulation,
@ -371,9 +371,10 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
wheres.push(`taken_at <= $${valueIndex++}`);
values.push(takenAfterInclusive.toISOString());
}
if (title) {
wheres.push(`LOWER(title) LIKE $${valueIndex++}`);
values.push(`%${title.toLowerCase()}%`);
if (query) {
// eslint-disable-next-line max-len
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valueIndex++}`);
values.push(`%${query.toLocaleLowerCase()}%`);
}
if (tag) {
wheres.push(`$${valueIndex++}=ANY(tags)`);

View File

@ -20,10 +20,12 @@ import { toastSuccess } from '@/toast';
import { ConfigChecklistStatus } from './config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
import { HiSparkles } from 'react-icons/hi';
export default function SiteChecklistClient({
hasPostgres,
hasStorage,
hasVercelPostgres,
hasVercelKV,
hasStorageProvider,
hasVercelBlobStorage,
hasCloudflareR2Storage,
hasAwsS3Storage,
@ -41,9 +43,13 @@ export default function SiteChecklistClient({
isBlurEnabled,
isGeoPrivacyEnabled,
isPriorityOrderEnabled,
isAiTextGenerationEnabled,
aiTextAutoGeneratedFields,
hasAiTextAutoGeneratedFields,
isPublicApiEnabled,
isOgTextBottomAligned,
gridAspectRatio,
hasGridAspectRatio,
showRefreshButton,
secret,
}: ConfigChecklistStatus & {
@ -93,10 +99,16 @@ export default function SiteChecklistClient({
}}
/>;
const renderEnvVar = (variable: string) =>
const renderEnvVar = (
variable: string,
minimal?: boolean,
) =>
<div
key={variable}
className="overflow-x-scroll overflow-y-hidden"
className={clsx(
'overflow-x-scroll overflow-y-hidden',
minimal && 'inline-flex',
)}
>
<span className="inline-flex items-center gap-1">
<span className={clsx(
@ -106,13 +118,13 @@ export default function SiteChecklistClient({
)}>
`{variable}`
</span>
{renderCopyButton(variable, variable, true)}
{!minimal && renderCopyButton(variable, variable, true)}
</span>
</div>;
const renderEnvVars = (variables: string[]) =>
<div className="py-1 space-y-1">
{variables.map(renderEnvVar)}
{variables.map(envVar => renderEnvVar(envVar))}
</div>;
const renderSubStatus = (
@ -134,7 +146,7 @@ export default function SiteChecklistClient({
>
<ChecklistRow
title="Setup database"
status={hasPostgres}
status={hasVercelPostgres}
isPending={isPendingPage}
>
{renderLink(
@ -146,13 +158,13 @@ export default function SiteChecklistClient({
and connect to project
</ChecklistRow>
<ChecklistRow
title={!hasStorage
title={!hasStorageProvider
? 'Setup storage (one of the following)'
: hasMultipleStorageProviders
// eslint-disable-next-line max-len
? `Setup storage (new uploads go to: ${labelForStorage(currentStorage)})`
: 'Setup storage'}
status={hasStorage}
status={hasStorageProvider}
isPending={isPendingPage}
>
{renderSubStatus(
@ -260,13 +272,57 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_SITE_DOMAIN'])}
</ChecklistRow>
</Checklist>
<Checklist
title="AI Text Generation"
icon={<HiSparkles />}
experimental
optional
>
<ChecklistRow
title="Add OpenAI Secret Key"
status={isAiTextGenerationEnabled}
isPending={isPendingPage}
optional
>
Store your OpenAI secret key in order to add experimental support
for AI-generated text descriptions and enable an invisible field
called {'"Semantic Description"'} used to support CMD-K search
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow>
<ChecklistRow
title="Enable Rate Limiting"
status={hasVercelKV}
isPending={isPendingPage}
optional
>
{renderLink(
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
'Create Vercel KV store',
)}
{' '}
and connect to project in order to enable rate limiting
</ChecklistRow>
<ChecklistRow
// eslint-disable-next-line max-len
title={`Auto-generated fields: ${aiTextAutoGeneratedFields.join(', ')}`}
status={hasAiTextAutoGeneratedFields}
isPending={isPendingPage}
optional
>
Comma-separated fields to auto-generate when
uploading photos. Accepted values: title, caption,
tags, description, all, or none (default is {'"all"'}).
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Settings"
icon={<BiCog size={16} />}
optional
>
<ChecklistRow
title="Pro Mode"
title="Pro mode"
status={isProModeEnabled}
isPending={isPendingPage}
optional
@ -297,7 +353,7 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
<ChecklistRow
title="Geo Privacy"
title="Geo privacy"
status={isGeoPrivacyEnabled}
isPending={isPendingPage}
optional
@ -307,7 +363,7 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Priority Order"
title="Priority order"
status={isPriorityOrderEnabled}
isPending={isPendingPage}
optional
@ -327,7 +383,7 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
</ChecklistRow>
<ChecklistRow
title="Show Repo Link"
title="Show repo link"
status={showRepoLink}
isPending={isPendingPage}
optional
@ -355,24 +411,24 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title={`Grid Aspect Ratio: ${gridAspectRatio}`}
status={gridAspectRatio !== 0}
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
isPending={isPendingPage}
optional
>
Set environment variable to any number to enforce aspect ratio
{' '}
(defaults to {'"1"'}, i.e., square)set to {'"0"'} to disable:
(default is {'"1"'}, i.e., square)set to {'"0"'} to disable:
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
</ChecklistRow>
<ChecklistRow
title="Legacy OG Text Alignment"
title="Legacy OG text alignment"
status={isOgTextBottomAligned}
isPending={isPendingPage}
optional
>
Set environment variable to {'"BOTTOM"'} to
keep OG image text bottom aligned (default is top):
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</Checklist>

View File

@ -1,3 +1,4 @@
import { parseAiAutoGeneratedFieldsText } from '@/photo/ai';
import type { StorageType } from '@/services/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
@ -37,6 +38,14 @@ export const SITE_DESCRIPTION =
process.env.NEXT_PUBLIC_SITE_DESCRIPTION ||
SITE_DOMAIN;
// STORAGE: VERCEL POSTGRES
export const HAS_VERCEL_POSTGRES =
(process.env.POSTGRES_HOST ?? '').length > 0;
// STORAGE: VERCEL KV
export const HAS_VERCEL_KV =
(process.env.KV_URL ?? '').length > 0;
// STORAGE: VERCEL BLOB
export const HAS_VERCEL_BLOB_STORAGE =
(process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0;
@ -89,6 +98,10 @@ export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
export const GEO_PRIVACY_ENABLED =
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
export const AI_TEXT_GENERATION_ENABLED =
Boolean(process.env.OPENAI_SECRET_KEY);
export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText(
process.env.AI_TEXT_AUTO_GENERATED_FIELDS);
export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const PUBLIC_API_ENABLED =
@ -110,11 +123,12 @@ export const OG_TEXT_BOTTOM_ALIGNMENT =
export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1;
export const CONFIG_CHECKLIST_STATUS = {
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
hasVercelPostgres: HAS_VERCEL_POSTGRES,
hasVercelKV: HAS_VERCEL_KV,
hasVercelBlobStorage: HAS_VERCEL_BLOB_STORAGE,
hasCloudflareR2Storage: HAS_CLOUDFLARE_R2_STORAGE,
hasAwsS3Storage: HAS_AWS_S3_STORAGE,
hasStorage:
hasStorageProvider:
HAS_VERCEL_BLOB_STORAGE ||
HAS_CLOUDFLARE_R2_STORAGE ||
HAS_AWS_S3_STORAGE,
@ -134,16 +148,25 @@ export const CONFIG_CHECKLIST_STATUS = {
isStaticallyOptimized: STATICALLY_OPTIMIZED,
isBlurEnabled: BLUR_ENABLED,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
? ['none']
: AI_TEXT_AUTO_GENERATED_FIELDS
: ['all'],
hasAiTextAutoGeneratedFields:
Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS),
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isPublicApiEnabled: PUBLIC_API_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
gridAspectRatio: GRID_ASPECT_RATIO,
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
};
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
export const IS_SITE_READY =
CONFIG_CHECKLIST_STATUS.hasPostgres &&
CONFIG_CHECKLIST_STATUS.hasStorage &&
CONFIG_CHECKLIST_STATUS.hasVercelPostgres &&
CONFIG_CHECKLIST_STATUS.hasStorageProvider &&
CONFIG_CHECKLIST_STATUS.hasAuthSecret &&
CONFIG_CHECKLIST_STATUS.hasAdminUser;

View File

@ -19,21 +19,24 @@
}
.control,
button, .button,
input[type=text], input[type=email], input[type=password], select {
input[type=text], input[type=email], input[type=password], select, textarea {
@apply
px-2.5 py-2
border rounded-md
bg-main
border-gray-200 dark:border-gray-700
font-mono text-base leading-tight
min-h-[2.4rem]
font-mono text-base leading-tight
}
input[type=text], input[type=email], input[type=password], select, textarea {
@apply
text-[1rem] /* Prevent iOS auto-zoom behavior */
read-only:cursor-default
}
input[type=text], input[type=email], input[type=password], select {
@apply
text-[1rem] /* Prevent iOS auto-zoom behavior */
min-w-[20rem] read-only:cursor-default
min-h-[2.4rem]
}
input[type=text], input[type=email], input[type=password] {
input[type=text], input[type=email], input[type=password], textarea {
@apply
read-only:bg-gray-100
dark:read-only:bg-gray-900 dark:read-only:text-gray-400