Merge pull request #72 from sambecker/ai-content
Auto-generate photo meta with AI
This commit is contained in:
commit
432294731e
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@ -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"
|
||||
|
||||
24
README.md
24
README.md
@ -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 (will result in increased storage usage)
|
||||
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
|
||||
|
||||
42
__tests__/ai.test.ts
Normal file
42
__tests__/ai.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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.1.3",
|
||||
"next": "14.1.4",
|
||||
"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
613
pnpm-lock.yaml
generated
@ -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.1.3)(react@18.2.0)
|
||||
version: 1.2.2(next@14.1.4)(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.1.3)(react@18.2.0)
|
||||
version: 1.0.10(next@14.1.4)(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.1.3
|
||||
version: 14.1.3(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: 14.1.4
|
||||
version: 14.1.4(@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.1.3)(react@18.2.0)
|
||||
version: 5.0.0-beta.15(next@14.1.4)(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.1.3:
|
||||
resolution: {integrity: sha512-VhgXTvrgeBRxNPjyfBsDIMvgsKDxjlpw4IAUsHCX8Gjl1vtHUYRT3+xfQ/wwvLPDd/6kqfLqk9Pt4+7gysuCKQ==}
|
||||
/@next/env@14.1.4:
|
||||
resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==}
|
||||
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.1.3:
|
||||
resolution: {integrity: sha512-LALu0yIBPRiG9ANrD5ncB3pjpO0Gli9ZLhxdOu6ZUNf3x1r3ea1rd9Q+4xxUkGrUXLqKVK9/lDkpYIJaCJ6AHQ==}
|
||||
/@next/swc-darwin-arm64@14.1.4:
|
||||
resolution: {integrity: sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
@ -1570,8 +1582,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-darwin-x64@14.1.3:
|
||||
resolution: {integrity: sha512-E/9WQeXxkqw2dfcn5UcjApFgUq73jqNKaE5bysDm58hEUdUGedVrnRhblhJM7HbCZNhtVl0j+6TXsK0PuzXTCg==}
|
||||
/@next/swc-darwin-x64@14.1.4:
|
||||
resolution: {integrity: sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
@ -1579,8 +1591,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-arm64-gnu@14.1.3:
|
||||
resolution: {integrity: sha512-USArX9B+3rZSXYLFvgy0NVWQgqh6LHWDmMt38O4lmiJNQcwazeI6xRvSsliDLKt+78KChVacNiwvOMbl6g6BBw==}
|
||||
/@next/swc-linux-arm64-gnu@14.1.4:
|
||||
resolution: {integrity: sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@ -1588,8 +1600,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-arm64-musl@14.1.3:
|
||||
resolution: {integrity: sha512-esk1RkRBLSIEp1qaQXv1+s6ZdYzuVCnDAZySpa62iFTMGTisCyNQmqyCTL9P+cLJ4N9FKCI3ojtSfsyPHJDQNw==}
|
||||
/@next/swc-linux-arm64-musl@14.1.4:
|
||||
resolution: {integrity: sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@ -1597,8 +1609,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-x64-gnu@14.1.3:
|
||||
resolution: {integrity: sha512-8uOgRlYEYiKo0L8YGeS+3TudHVDWDjPVDUcST+z+dUzgBbTEwSSIaSgF/vkcC1T/iwl4QX9iuUyUdQEl0Kxalg==}
|
||||
/@next/swc-linux-x64-gnu@14.1.4:
|
||||
resolution: {integrity: sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@ -1606,8 +1618,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-x64-musl@14.1.3:
|
||||
resolution: {integrity: sha512-DX2zqz05ziElLoxskgHasaJBREC5Y9TJcbR2LYqu4r7naff25B4iXkfXWfcp69uD75/0URmmoSgT8JclJtrBoQ==}
|
||||
/@next/swc-linux-x64-musl@14.1.4:
|
||||
resolution: {integrity: sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@ -1615,8 +1627,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-win32-arm64-msvc@14.1.3:
|
||||
resolution: {integrity: sha512-HjssFsCdsD4GHstXSQxsi2l70F/5FsRTRQp8xNgmQs15SxUfUJRvSI9qKny/jLkY3gLgiCR3+6A7wzzK0DBlfA==}
|
||||
/@next/swc-win32-arm64-msvc@14.1.4:
|
||||
resolution: {integrity: sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
@ -1624,8 +1636,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-win32-ia32-msvc@14.1.3:
|
||||
resolution: {integrity: sha512-DRuxD5axfDM1/Ue4VahwSxl1O5rn61hX8/sF0HY8y0iCbpqdxw3rB3QasdHn/LJ6Wb2y5DoWzXcz3L1Cr+Thrw==}
|
||||
/@next/swc-win32-ia32-msvc@14.1.4:
|
||||
resolution: {integrity: sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
@ -1633,8 +1645,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-win32-x64-msvc@14.1.3:
|
||||
resolution: {integrity: sha512-uC2DaDoWH7h1P/aJ4Fok3Xiw6P0Lo4ez7NbowW2VGNXw/Xv6tOuLUcxhBYZxsSUJtpeknCi8/fvnSpyCFp4Rcg==}
|
||||
/@next/swc-win32-x64-msvc@14.1.4:
|
||||
resolution: {integrity: sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@ -2780,6 +2792,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:
|
||||
@ -2825,6 +2845,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:
|
||||
@ -3083,7 +3116,32 @@ packages:
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
dev: false
|
||||
|
||||
/@vercel/analytics@1.2.2(next@14.1.3)(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.1.4)(react@18.2.0):
|
||||
resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==}
|
||||
peerDependencies:
|
||||
next: '>= 13'
|
||||
@ -3094,7 +3152,7 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
next: 14.1.3(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
|
||||
next: 14.1.4(@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
|
||||
@ -3109,6 +3167,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'}
|
||||
@ -3119,7 +3184,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.1.3)(react@18.2.0):
|
||||
/@vercel/speed-insights@1.0.10(next@14.1.4)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21):
|
||||
resolution: {integrity: sha512-4uzdKB0RW6Ff2FkzshzjZ+RlJfLPxgm/00i0XXgxfMPhwnnsk92YgtqsxT9OcPLdJUyVU1DqFlSWWjIQMPkh0g==}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
@ -3143,8 +3208,83 @@ packages:
|
||||
vue-router:
|
||||
optional: true
|
||||
dependencies:
|
||||
next: 14.1.3(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
|
||||
next: 14.1.4(@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:
|
||||
@ -3152,6 +3292,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:
|
||||
@ -3187,6 +3334,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:
|
||||
@ -3424,6 +3615,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}
|
||||
@ -3500,6 +3697,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'}
|
||||
@ -3655,11 +3856,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'}
|
||||
@ -3722,6 +3932,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
|
||||
@ -3805,6 +4025,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
|
||||
@ -3966,11 +4202,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'}
|
||||
@ -4508,11 +4755,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'}
|
||||
@ -4647,6 +4914,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'}
|
||||
@ -4656,6 +4927,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
|
||||
@ -4930,6 +5209,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'}
|
||||
@ -5043,6 +5328,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'}
|
||||
@ -5136,6 +5425,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'}
|
||||
@ -5820,6 +6115,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'}
|
||||
@ -5879,6 +6184,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'}
|
||||
@ -5931,6 +6240,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'}
|
||||
@ -5949,6 +6265,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
|
||||
@ -6036,6 +6364,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}
|
||||
@ -6052,7 +6386,7 @@ packages:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
dev: false
|
||||
|
||||
/next-auth@5.0.0-beta.15(next@14.1.3)(react@18.2.0):
|
||||
/next-auth@5.0.0-beta.15(next@14.1.4)(react@18.2.0):
|
||||
resolution: {integrity: sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
@ -6069,7 +6403,7 @@ packages:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@auth/core': 0.28.0
|
||||
next: 14.1.3(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
|
||||
next: 14.1.4(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
@ -6083,8 +6417,8 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/next@14.1.3(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-oexgMV2MapI0UIWiXKkixF8J8ORxpy64OuJ/J9oVUmIthXOUCcuVEZX+dtpgq7wIfIqtBwQsKEDXejcjTsan9g==}
|
||||
/next@14.1.4(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==}
|
||||
engines: {node: '>=18.17.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -6098,7 +6432,7 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@next/env': 14.1.3
|
||||
'@next/env': 14.1.4
|
||||
'@swc/helpers': 0.5.2
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001591
|
||||
@ -6108,20 +6442,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.1.3
|
||||
'@next/swc-darwin-x64': 14.1.3
|
||||
'@next/swc-linux-arm64-gnu': 14.1.3
|
||||
'@next/swc-linux-arm64-musl': 14.1.3
|
||||
'@next/swc-linux-x64-gnu': 14.1.3
|
||||
'@next/swc-linux-x64-musl': 14.1.3
|
||||
'@next/swc-win32-arm64-msvc': 14.1.3
|
||||
'@next/swc-win32-ia32-msvc': 14.1.3
|
||||
'@next/swc-win32-x64-msvc': 14.1.3
|
||||
'@next/swc-darwin-arm64': 14.1.4
|
||||
'@next/swc-darwin-x64': 14.1.4
|
||||
'@next/swc-linux-arm64-gnu': 14.1.4
|
||||
'@next/swc-linux-arm64-musl': 14.1.4
|
||||
'@next/swc-linux-x64-gnu': 14.1.4
|
||||
'@next/swc-linux-x64-musl': 14.1.4
|
||||
'@next/swc-win32-arm64-msvc': 14.1.4
|
||||
'@next/swc-win32-ia32-msvc': 14.1.4
|
||||
'@next/swc-win32-x64-msvc': 14.1.4
|
||||
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
|
||||
@ -6254,6 +6605,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
|
||||
@ -6359,6 +6727,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'}
|
||||
@ -6853,6 +7229,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
|
||||
@ -6927,6 +7317,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:
|
||||
@ -6958,6 +7367,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'}
|
||||
@ -7145,6 +7563,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
|
||||
@ -7242,6 +7708,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'}
|
||||
@ -7456,6 +7926,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'}
|
||||
@ -7482,6 +7960,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'}
|
||||
@ -7495,6 +7989,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'}
|
||||
@ -7543,6 +8051,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:
|
||||
@ -7725,3 +8240,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
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -44,7 +44,7 @@ export const {
|
||||
},
|
||||
});
|
||||
|
||||
export const safelyRunServerAdminAction = async <T>(
|
||||
export const safelyRunAdminServerAction = async <T>(
|
||||
callback: () => T,
|
||||
): Promise<T> => {
|
||||
const session = await auth();
|
||||
|
||||
@ -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>
|
||||
</>}
|
||||
|
||||
@ -7,6 +7,7 @@ export default function Badge({
|
||||
highContrast,
|
||||
uppercase,
|
||||
interactive,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
type?: 'large' | 'small' | 'text-only'
|
||||
@ -14,6 +15,7 @@ export default function Badge({
|
||||
highContrast?: boolean
|
||||
uppercase?: boolean
|
||||
interactive?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const stylesForType = () => {
|
||||
switch (type) {
|
||||
@ -44,6 +46,7 @@ export default function Badge({
|
||||
'leading-none',
|
||||
stylesForType(),
|
||||
uppercase && 'uppercase tracking-wider',
|
||||
className,
|
||||
)}>
|
||||
<span className={clsx(dimContent && 'opacity-50')}>
|
||||
{children}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import StatusIcon from './StatusIcon';
|
||||
import ExperimentalBadge from './ExperimentalBadge';
|
||||
|
||||
export default function ChecklistRow({
|
||||
title,
|
||||
status,
|
||||
isPending,
|
||||
optional,
|
||||
experimental,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
status: boolean
|
||||
isPending: boolean
|
||||
optional?: boolean
|
||||
experimental?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
@ -25,8 +28,13 @@ export default function ChecklistRow({
|
||||
loading={isPending}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="font-bold dark:text-gray-300">
|
||||
<div className={clsx(
|
||||
'flex flex-wrap items-center gap-2',
|
||||
'font-bold dark:text-gray-300',
|
||||
)}>
|
||||
{title}
|
||||
{experimental &&
|
||||
<ExperimentalBadge className="translate-y-[0.5px]" />}
|
||||
</div>
|
||||
<div>
|
||||
{children}
|
||||
|
||||
@ -27,6 +27,7 @@ export type CommandKSection = {
|
||||
accessory?: ReactNode
|
||||
items: {
|
||||
label: string
|
||||
keywords?: string[]
|
||||
annotation?: ReactNode
|
||||
annotationAria?: string
|
||||
accessory?: ReactNode
|
||||
@ -157,8 +158,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
|
||||
@ -223,16 +229,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',
|
||||
|
||||
20
src/components/ExperimentalBadge.tsx
Normal file
20
src/components/ExperimentalBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 { Tags } 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?: Tags
|
||||
uniqueTags: Tags
|
||||
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>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
Photo,
|
||||
altTextForPhoto,
|
||||
shouldShowCameraDataForPhoto,
|
||||
shouldShowExifDataForPhoto,
|
||||
titleForPhoto,
|
||||
@ -54,7 +55,7 @@ export default function PhotoLarge({
|
||||
contentMain={
|
||||
<ImageLarge
|
||||
className="w-full"
|
||||
alt={titleForPhoto(photo)}
|
||||
alt={altTextForPhoto(photo)}
|
||||
href={pathForPhoto(photo, primaryTag)}
|
||||
src={photo.url}
|
||||
aspectRatio={photo.aspectRatio}
|
||||
|
||||
@ -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';
|
||||
@ -34,7 +34,7 @@ export default function PhotoSmall({
|
||||
aspectRatio={photo.aspectRatio}
|
||||
blurData={photo.blurData}
|
||||
className="w-full"
|
||||
alt={titleForPhoto(photo)}
|
||||
alt={altTextForPhoto(photo)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@ -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';
|
||||
@ -31,7 +31,7 @@ export default function PhotoTiny({
|
||||
src={photo.url}
|
||||
aspectRatio={photo.aspectRatio}
|
||||
blurData={photo.blurData}
|
||||
alt={titleForPhoto(photo)}
|
||||
alt={altTextForPhoto(photo)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@ -5,19 +5,32 @@ import { PATH_ADMIN_UPLOADS } from '@/site/paths';
|
||||
import { PhotoFormData } from './form';
|
||||
import { Tags } from '@/tag';
|
||||
import PhotoForm from './form/PhotoForm';
|
||||
import { useState } from 'react';
|
||||
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: Tags
|
||||
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>
|
||||
|
||||
@ -33,10 +33,12 @@ import {
|
||||
import { extractExifDataFromBlobPath } from './server';
|
||||
import { TAG_FAVS, isTagFavs } from '@/tag';
|
||||
import { convertPhotoToPhotoDbInsert } 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);
|
||||
@ -52,7 +54,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);
|
||||
@ -67,7 +69,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;
|
||||
@ -88,7 +90,7 @@ export async function deletePhotoAction(
|
||||
photoUrl: string,
|
||||
shouldRedirect?: boolean,
|
||||
) {
|
||||
return safelyRunServerAdminAction(async () => {
|
||||
return safelyRunAdminServerAction(async () => {
|
||||
await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
|
||||
revalidateAllKeysAndPaths();
|
||||
if (shouldRedirect) {
|
||||
@ -98,7 +100,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,
|
||||
@ -107,7 +109,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);
|
||||
@ -118,7 +120,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;
|
||||
|
||||
@ -132,7 +134,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();
|
||||
@ -146,7 +148,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);
|
||||
@ -159,7 +161,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);
|
||||
@ -179,5 +181,13 @@ 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]));
|
||||
}
|
||||
|
||||
63
src/photo/ai/AiButton.tsx
Normal file
63
src/photo/ai/AiButton.tsx
Normal 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
65
src/photo/ai/index.ts
Normal 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] ?? '',
|
||||
};
|
||||
};
|
||||
134
src/photo/ai/useAiImageQueries.ts
Normal file
134
src/photo/ai/useAiImageQueries.ts
Normal file
@ -0,0 +1,134 @@
|
||||
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 hasContent = Boolean(
|
||||
title ||
|
||||
caption ||
|
||||
tags ||
|
||||
semanticDescription
|
||||
);
|
||||
|
||||
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,
|
||||
hasContent,
|
||||
isLoading,
|
||||
isLoadingTitle,
|
||||
isLoadingCaption,
|
||||
isLoadingTags,
|
||||
isLoadingSemantic,
|
||||
setImageData,
|
||||
};
|
||||
}
|
||||
53
src/photo/ai/useAiImageQuery.ts
Normal file
53
src/photo/ai/useAiImageQuery.ts
Normal 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;
|
||||
};
|
||||
40
src/photo/ai/useTitleCaptionAiImageQuery.ts
Normal file
40
src/photo/ai/useTitleCaptionAiImageQuery.ts
Normal 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;
|
||||
}
|
||||
@ -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 { Tags, 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?: Tags
|
||||
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?.hasContent
|
||||
? { ...data, title: aiContent?.title }
|
||||
: data),
|
||||
[aiContent?.title, aiContent?.hasContent]);
|
||||
|
||||
useEffect(() =>
|
||||
setFormData(data => aiContent?.hasContent
|
||||
? { ...data, caption: aiContent?.caption }
|
||||
: data),
|
||||
[aiContent?.caption, aiContent?.hasContent]);
|
||||
|
||||
useEffect(() =>
|
||||
setFormData(data => aiContent?.hasContent
|
||||
? { ...data, tags: aiContent?.tags }
|
||||
: data),
|
||||
[aiContent?.tags, aiContent?.hasContent]);
|
||||
|
||||
useEffect(() =>
|
||||
setFormData(data => aiContent?.hasContent
|
||||
? { ...data, semanticDescription: aiContent?.semanticDescription }
|
||||
: data),
|
||||
[aiContent?.semanticDescription, aiContent?.hasContent]);
|
||||
|
||||
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'}
|
||||
|
||||
@ -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 = (
|
||||
|
||||
29
src/photo/form/usePhotoFormParent.ts
Normal file
29
src/photo/form/usePhotoFormParent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -168,6 +168,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';
|
||||
|
||||
@ -247,3 +250,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
76
src/services/openai.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
@ -294,7 +294,7 @@ export type GetPhotosOptions = {
|
||||
sortBy?: 'createdAt' | 'takenAt' | 'priority'
|
||||
limit?: number
|
||||
offset?: number
|
||||
title?: string
|
||||
query?: string
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
@ -344,7 +344,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
|
||||
limit = PHOTO_DEFAULT_LIMIT,
|
||||
offset = 0,
|
||||
title,
|
||||
query,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
@ -370,9 +370,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)`);
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
import { formatCameraText } from '@/camera';
|
||||
import { authCached } from '@/auth/cache';
|
||||
import { getPhotos } from '@/services/vercel-postgres';
|
||||
import { photoQuantityText, titleForPhoto } from '@/photo';
|
||||
import { getKeywordsForPhoto, photoQuantityText, titleForPhoto } from '@/photo';
|
||||
import PhotoTiny from '@/photo/PhotoTiny';
|
||||
import { formatDate } from '@/utility/date';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
@ -139,15 +139,14 @@ export default async function CommandK() {
|
||||
]}
|
||||
onQueryChange={async (query) => {
|
||||
'use server';
|
||||
const photos = (await getPhotos({ title: query, limit: 10 }))
|
||||
.filter(({ title }) => Boolean(title));
|
||||
const photos = (await getPhotos({ query, limit: 10 }));
|
||||
return photos.length > 0
|
||||
? [{
|
||||
heading: 'Photos',
|
||||
accessory: <TbPhoto size={14} />,
|
||||
items: photos.map(photo => ({
|
||||
accessory: <PhotoTiny photo={photo} />,
|
||||
label: titleForPhoto(photo),
|
||||
keywords: getKeywordsForPhoto(photo),
|
||||
annotation: <>
|
||||
<span className="hidden sm:inline-block">
|
||||
{formatDate(photo.takenAt)}
|
||||
@ -156,6 +155,7 @@ export default async function CommandK() {
|
||||
{formatDate(photo.takenAt, true)}
|
||||
</span>
|
||||
</>,
|
||||
accessory: <PhotoTiny photo={photo} />,
|
||||
path: pathForPhoto(photo),
|
||||
})),
|
||||
}]
|
||||
|
||||
@ -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,
|
||||
@ -40,9 +42,13 @@ export default function SiteChecklistClient({
|
||||
isBlurEnabled,
|
||||
isGeoPrivacyEnabled,
|
||||
isPriorityOrderEnabled,
|
||||
isAiTextGenerationEnabled,
|
||||
aiTextAutoGeneratedFields,
|
||||
hasAiTextAutoGeneratedFields,
|
||||
isPublicApiEnabled,
|
||||
isOgTextBottomAligned,
|
||||
gridAspectRatio,
|
||||
hasGridAspectRatio,
|
||||
showRefreshButton,
|
||||
secret,
|
||||
}: ConfigChecklistStatus & {
|
||||
@ -92,10 +98,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(
|
||||
@ -105,13 +117,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 = (
|
||||
@ -133,7 +145,7 @@ export default function SiteChecklistClient({
|
||||
>
|
||||
<ChecklistRow
|
||||
title="Setup database"
|
||||
status={hasPostgres}
|
||||
status={hasVercelPostgres}
|
||||
isPending={isPendingPage}
|
||||
>
|
||||
{renderLink(
|
||||
@ -145,13 +157,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(
|
||||
@ -259,13 +271,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
|
||||
@ -275,7 +331,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Image Blur"
|
||||
title="Image blur"
|
||||
status={isBlurEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -285,7 +341,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Geo Privacy"
|
||||
title="Geo privacy"
|
||||
status={isGeoPrivacyEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -295,7 +351,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Priority Order"
|
||||
title="Priority order"
|
||||
status={isPriorityOrderEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -315,7 +371,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
|
||||
@ -343,24 +399,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>
|
||||
|
||||
@ -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;
|
||||
@ -84,6 +93,10 @@ export const CURRENT_STORAGE: StorageType =
|
||||
export const PRO_MODE_ENABLED = process.env.NEXT_PUBLIC_PRO_MODE === '1';
|
||||
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 = process.env.NEXT_PUBLIC_PUBLIC_API === '1';
|
||||
@ -100,11 +113,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,
|
||||
@ -123,16 +137,25 @@ export const CONFIG_CHECKLIST_STATUS = {
|
||||
isProModeEnabled: PRO_MODE_ENABLED,
|
||||
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;
|
||||
|
||||
@ -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 {
|
||||
input[type=text], input[type=email], input[type=password], select, textarea {
|
||||
@apply
|
||||
text-[1rem] /* Prevent iOS auto-zoom behavior */
|
||||
min-w-[20rem] read-only:cursor-default
|
||||
}
|
||||
input[type=text], input[type=email], input[type=password] {
|
||||
input[type=text], input[type=email], input[type=password], select {
|
||||
@apply
|
||||
min-h-[2.4rem]
|
||||
}
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user