Merge pull request #192 from sambecker/admin-info

Introduce Admin Insights 
This commit is contained in:
Sam Becker 2025-02-15 23:36:25 -06:00 committed by GitHub
commit d63710cc98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
171 changed files with 1613 additions and 972 deletions

View File

@ -1,47 +1,44 @@
import { getGitHubMetaWithFallback, getGitHubPublicFork } from '@/admin/github';
import { TEMPLATE_BASE_OWNER, TEMPLATE_BASE_REPO } from '@/site/config';
import {
getGitHubMeta,
getGitHubPublicFork,
} from '@/platforms/github';
import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_NAME } from '@/app-core/config';
describe('GitHub', () => {
it('fetches base repo meta', async () => {
const meta = await getGitHubMetaWithFallback({
owner: TEMPLATE_BASE_OWNER,
repo: TEMPLATE_BASE_REPO,
const meta = await getGitHubMeta({
owner: TEMPLATE_REPO_OWNER,
repo: TEMPLATE_REPO_NAME,
});
expect(meta).toBeDefined();
expect(meta.url).toBeDefined();
expect(meta.urlRepo).toBeDefined();
expect(meta.isForkedFromBase).toEqual(false);
expect(meta.label).toBeDefined();
expect(meta.description).toBeDefined();
expect(meta.isBehind).toEqual(false);
expect(meta.isBaseRepo).toBe(true);
});
it('fetches fork meta', async () => {
const fork = await getGitHubPublicFork();
const metaFork = await getGitHubMetaWithFallback(fork);
const metaFork = await getGitHubMeta(fork);
expect(metaFork.isForkedFromBase).toEqual(true);
});
it('handles nonexistent repos', async () => {
const meta = await getGitHubMetaWithFallback({
const meta = await getGitHubMeta({
owner: 'nonexistent',
repo: 'nonexistent',
});
expect(meta).toBeDefined();
expect(meta.url).toBeDefined();
expect(meta.urlRepo).toBeDefined();
expect(meta.isForkedFromBase).toEqual(false);
expect(meta.label).toEqual('Unknown');
expect(meta.description).toEqual('Unknown');
expect(meta.isBehind).toBeUndefined();
});
it('handles fetch errors', async () => {
const meta = await getGitHubMetaWithFallback({
const meta = await getGitHubMeta({
owner: 'gibberish / / *',
repo: 'bad text for a url.com',
});
expect(meta).toBeDefined();
expect(meta.url).toBeDefined();
expect(meta.urlRepo).toBeDefined();
expect(meta.isForkedFromBase).toEqual(false);
expect(meta.label).toEqual('Unknown');
expect(meta.description).toEqual('Unknown');
expect(meta.isBehind).toBeUndefined();
});
});

View File

@ -12,7 +12,7 @@ import {
isPathProtected,
isPathTag,
isPathTagPhoto,
} from '@/site/paths';
} from '@/app-core/paths';
import { TAG_HIDDEN } from '@/tag';
const PHOTO_ID = 'UsKSGcbt';

View File

@ -1,7 +1,7 @@
{
"name": "exif-photo-blog",
"scripts": {
"dev": "next dev",
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -17,19 +17,19 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@upstash/ratelimit": "^2.0.5",
"@vercel/analytics": "^1.4.1",
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^0.27.1",
"@vercel/kv": "^3.0.0",
"@vercel/speed-insights": "^1.1.0",
"ai": "^4.1.26",
"@vercel/speed-insights": "^1.2.0",
"ai": "^4.1.34",
"camelcase-keys": "^9.1.3",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"exifr": "^7.1.3",
"framer-motion": "^12.4.1",
"framer-motion": "^12.4.2",
"nanoid": "^5.0.9",
"next": "15.1.6",
"next": "15.1.7",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
"pg": "^8.13.1",
@ -45,10 +45,10 @@
"viewerjs": "^1.11.7"
},
"devDependencies": {
"@next/bundle-analyzer": "15.1.6",
"@next/bundle-analyzer": "15.1.7",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.5",
"@tailwindcss/postcss": "^4.0.6",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
@ -61,11 +61,11 @@
"clsx": "^2.1.1",
"cross-fetch": "^4.1.0",
"eslint": "9.20.0",
"eslint-config-next": "15.1.6",
"eslint-config-next": "15.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "8.5.1",
"tailwindcss": "4.0.5",
"postcss": "8.5.2",
"tailwindcss": "4.0.6",
"ts-node": "^10.9.2",
"typescript": "5.7.3"
}

345
pnpm-lock.yaml generated
View File

@ -33,8 +33,8 @@ importers:
specifier: ^2.0.5
version: 2.0.5(@upstash/redis@1.34.3)
'@vercel/analytics':
specifier: ^1.4.1
version: 1.4.1(next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))
specifier: ^1.5.0
version: 1.5.0(next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))
'@vercel/blob':
specifier: ^0.27.1
version: 0.27.1
@ -42,11 +42,11 @@ importers:
specifier: ^3.0.0
version: 3.0.0
'@vercel/speed-insights':
specifier: ^1.1.0
version: 1.1.0(next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))
specifier: ^1.2.0
version: 1.2.0(next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))
ai:
specifier: ^4.1.26
version: 4.1.26(react@19.0.0)(zod@3.23.8)
specifier: ^4.1.34
version: 4.1.34(react@19.0.0)(zod@3.23.8)
camelcase-keys:
specifier: ^9.1.3
version: 9.1.3
@ -63,17 +63,17 @@ importers:
specifier: ^7.1.3
version: 7.1.3
framer-motion:
specifier: ^12.4.1
version: 12.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: ^12.4.2
version: 12.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
nanoid:
specifier: ^5.0.9
version: 5.0.9
next:
specifier: 15.1.6
version: 15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.1.7
version: 15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
version: 5.0.0-beta.25(next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
next-themes:
specifier: ^0.4.4
version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -112,17 +112,17 @@ importers:
version: 1.11.7
devDependencies:
'@next/bundle-analyzer':
specifier: 15.1.6
version: 15.1.6
specifier: 15.1.7
version: 15.1.7
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@4.0.5)
version: 0.1.1(tailwindcss@4.0.6)
'@tailwindcss/forms':
specifier: ^0.5.10
version: 0.5.10(tailwindcss@4.0.5)
version: 0.5.10(tailwindcss@4.0.6)
'@tailwindcss/postcss':
specifier: ^4.0.5
version: 4.0.5
specifier: ^4.0.6
version: 4.0.6
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.0
@ -160,8 +160,8 @@ importers:
specifier: 9.20.0
version: 9.20.0(jiti@2.4.2)
eslint-config-next:
specifier: 15.1.6
version: 15.1.6(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)
specifier: 15.1.7
version: 15.1.7(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.13.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))
@ -169,11 +169,11 @@ importers:
specifier: ^29.7.0
version: 29.7.0
postcss:
specifier: 8.5.1
version: 8.5.1
specifier: 8.5.2
version: 8.5.2
tailwindcss:
specifier: 4.0.5
version: 4.0.5
specifier: 4.0.6
version: 4.0.6
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.13.1)(typescript@5.7.3)
@ -205,8 +205,8 @@ packages:
resolution: {integrity: sha512-q1PJEZ0qD9rVR+8JFEd01/QM++csMT5UVwYXSN2u54BrVw/D8TZLTeg2FEfKK00DgAx0UtWd8XOhhwITP9BT5g==}
engines: {node: '>=18'}
'@ai-sdk/react@1.1.10':
resolution: {integrity: sha512-RTkEVYKq7qO6Ct3XdVTgbaCTyjX+q1HLqb+t2YvZigimzMCQbHkpZCtt2H2Fgpt1UOTqnAAlXjEAgTW3X60Y9g==}
'@ai-sdk/react@1.1.11':
resolution: {integrity: sha512-vfjZ7w2M+Me83HTMMrnnrmXotz39UDCMd27YQSrvt2f1YCLPloVpLhP+Y9TLZeFE/QiiRCrPYLDQm6aQJYJ9PQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -217,8 +217,8 @@ packages:
zod:
optional: true
'@ai-sdk/ui-utils@1.1.10':
resolution: {integrity: sha512-x+A1Nfy8RTSatdCe+7nRpHAZVzPFB6H+r+2JKoapSvrwsu9mw2pAbmFgV8Zaj94TsmUdTlO0/j97e63f+yYuWg==}
'@ai-sdk/ui-utils@1.1.11':
resolution: {integrity: sha512-1SC9W4VZLcJtxHRv4Y0aX20EFeaEP6gUvVqoKLBBtMLOgtcZrv/F/HQRjGavGugiwlS3dsVza4X+E78fiwtlTA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
@ -866,59 +866,59 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@next/bundle-analyzer@15.1.6':
resolution: {integrity: sha512-hGzQyDqJzFHcHNCyTqM3o05BpVq5tGnRODccZBVJDBf5Miv/26UJPMB0wh9L9j3ylgHC+0/v8BaBnBBek1rC6Q==}
'@next/bundle-analyzer@15.1.7':
resolution: {integrity: sha512-tESiAwTUEpzzxKMLDbQuPHvD+PFDjY+0O3R4T5bpjIo0cr5fvppbbllQbtksQbBEquT55Eu8JmDoOlc9YFv6Kw==}
'@next/env@15.1.6':
resolution: {integrity: sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==}
'@next/env@15.1.7':
resolution: {integrity: sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==}
'@next/eslint-plugin-next@15.1.6':
resolution: {integrity: sha512-+slMxhTgILUntZDGNgsKEYHUvpn72WP1YTlkmEhS51vnVd7S9jEEy0n9YAMcI21vUG4akTw9voWH02lrClt/yw==}
'@next/eslint-plugin-next@15.1.7':
resolution: {integrity: sha512-kRP7RjSxfTO13NE317ek3mSGzoZlI33nc/i5hs1KaWpK+egs85xg0DJ4p32QEiHnR0mVjuUfhRIun7awqfL7pQ==}
'@next/swc-darwin-arm64@15.1.6':
resolution: {integrity: sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==}
'@next/swc-darwin-arm64@15.1.7':
resolution: {integrity: sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.1.6':
resolution: {integrity: sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==}
'@next/swc-darwin-x64@15.1.7':
resolution: {integrity: sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.1.6':
resolution: {integrity: sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==}
'@next/swc-linux-arm64-gnu@15.1.7':
resolution: {integrity: sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.1.6':
resolution: {integrity: sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==}
'@next/swc-linux-arm64-musl@15.1.7':
resolution: {integrity: sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.1.6':
resolution: {integrity: sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==}
'@next/swc-linux-x64-gnu@15.1.7':
resolution: {integrity: sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.1.6':
resolution: {integrity: sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==}
'@next/swc-linux-x64-musl@15.1.7':
resolution: {integrity: sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.1.6':
resolution: {integrity: sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==}
'@next/swc-win32-arm64-msvc@15.1.7':
resolution: {integrity: sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.1.6':
resolution: {integrity: sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==}
'@next/swc-win32-x64-msvc@15.1.7':
resolution: {integrity: sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -1515,81 +1515,81 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
'@tailwindcss/node@4.0.5':
resolution: {integrity: sha512-ffTz4DX1cgr4XPuqjhm32YV6Lyx58R1CxAAnSFTamg6wXwfk3oWdb6exgAbGesPzvUgicTO0gwUdQGSsg4nNog==}
'@tailwindcss/node@4.0.6':
resolution: {integrity: sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==}
'@tailwindcss/oxide-android-arm64@4.0.5':
resolution: {integrity: sha512-kK/ik8aIAKWDIEYDZGUCJcnU1qU5sPoMBlVzPvtsUqiV6cSHcnVRUdkcLwKqTeUowzZtjjRiamELLd9Gb0x5BQ==}
'@tailwindcss/oxide-android-arm64@4.0.6':
resolution: {integrity: sha512-xDbym6bDPW3D2XqQqX3PjqW3CKGe1KXH7Fdkc60sX5ZLVUbzPkFeunQaoP+BuYlLc2cC1FoClrIRYnRzof9Sow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.0.5':
resolution: {integrity: sha512-vkbXFv0FfAEbrSa5NBjFEE+xi06ha7mxuxjY8LRn7d7/tBGrAZOEJnnsEbB6M1+x2pGRTjjei0XyTIXdVCglJA==}
'@tailwindcss/oxide-darwin-arm64@4.0.6':
resolution: {integrity: sha512-1f71/ju/tvyGl5c2bDkchZHy8p8EK/tDHCxlpYJ1hGNvsYihZNurxVpZ0DefpN7cNc9RTT8DjrRoV8xXZKKRjg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.0.5':
resolution: {integrity: sha512-PedA64rHBXEa4e6abBWE4Yj4gHulfPb5T+rBNnX+WGkjjge5Txa2oS99TLmJ5BPDkXXqz/Ba7oweWIDDG7i5NQ==}
'@tailwindcss/oxide-darwin-x64@4.0.6':
resolution: {integrity: sha512-s/hg/ZPgxFIrGMb0kqyeaqZt505P891buUkSezmrDY6lxv2ixIELAlOcUVTkVh245SeaeEiUVUPiUN37cwoL2g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.0.5':
resolution: {integrity: sha512-silz3nuZdEYDfic3v/ooVUQChj9hbxDSee43GCQNwr/iD9L4K/JsZtoNqr0w69pUkvWcKINOGOG0r7WqUqkAeg==}
'@tailwindcss/oxide-freebsd-x64@4.0.6':
resolution: {integrity: sha512-Z3Wo8FWZnmio8+xlcbb7JUo/hqRMSmhQw8IGIRoRJ7GmLR0C+25Wq+bEX/135xe/yEle2lFkhu9JBHd4wZYiig==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.0.5':
resolution: {integrity: sha512-ElneG75XS64B9I2G83A/Hc7EtNVOD5xahs7avq0aeW7mEX6CtMc8m8RCXMn3jGhz8enFE52l6QU0wO7iVkEtXQ==}
'@tailwindcss/oxide-linux-arm-gnueabihf@4.0.6':
resolution: {integrity: sha512-SNSwkkim1myAgmnbHs4EjXsPL7rQbVGtjcok5EaIzkHkCAVK9QBQsWeP2Jm2/JJhq4wdx8tZB9Y7psMzHYWCkA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.0.5':
resolution: {integrity: sha512-8yoXpWTeIFaByUaKy2qRAppznLVaDHP9xYCAbS3FG7+uUwHi8CHE4TcomM7eyamo0U7dbUIDgKMGoAX5s2iVrA==}
'@tailwindcss/oxide-linux-arm64-gnu@4.0.6':
resolution: {integrity: sha512-tJ+mevtSDMQhKlwCCuhsFEFg058kBiSy4TkoeBG921EfrHKmexOaCyFKYhVXy4JtkaeeOcjJnCLasEeqml4i+Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.0.5':
resolution: {integrity: sha512-BDlVSiiJ08GRz9KKnXgaPFs2fkukPF3pym6uK3oWEKW45jKlVGgybLqulcV5nLEqREOuyq4Rn4vnZss4/bbQ/g==}
'@tailwindcss/oxide-linux-arm64-musl@4.0.6':
resolution: {integrity: sha512-IoArz1vfuTR4rALXMUXI/GWWfx2EaO4gFNtBNkDNOYhlTD4NVEwE45nbBoojYiTulajI4c2XH8UmVEVJTOJKxA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.0.5':
resolution: {integrity: sha512-DYgieNDRkTy69bWPgdsc47nAXa74P63P/RetUwYM9vYj5USyOfHCEcqIthkCuYw3dXKBhjgwe697TmL2g2jpAw==}
'@tailwindcss/oxide-linux-x64-gnu@4.0.6':
resolution: {integrity: sha512-QtsUfLkEAeWAC3Owx9Kg+7JdzE+k9drPhwTAXbXugYB9RZUnEWWx5x3q/au6TvUYcL+n0RBqDEO2gucZRvRFgQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.0.5':
resolution: {integrity: sha512-z2RzUvOQl0ZqrZqmCFP53tJbBXQ3UmLD/E6J7+q0e+4VaFnXCcIYTfQbHgI8f3fash+q6gK80Ko/ywEQ+bvv6Q==}
'@tailwindcss/oxide-linux-x64-musl@4.0.6':
resolution: {integrity: sha512-QthvJqIji2KlGNwLcK/PPYo7w1Wsi/8NK0wAtRGbv4eOPdZHkQ9KUk+oCoP20oPO7i2a6X1aBAFQEL7i08nNMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-win32-arm64-msvc@4.0.5':
resolution: {integrity: sha512-ho1dJ4o5Q8nAOxdMkbfBu5aSqI+/bzQ0jEeHcXaEdEJzf2fSWs3HY7bIKtE6vQS8c4SmSBvls7IhGPuJxNg+2Q==}
'@tailwindcss/oxide-win32-arm64-msvc@4.0.6':
resolution: {integrity: sha512-+oka+dYX8jy9iP00DJ9Y100XsqvbqR5s0yfMZJuPR1H/lDVtDfsZiSix1UFBQ3X1HWxoEEl6iXNJHWd56TocVw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.0.5':
resolution: {integrity: sha512-yjw6JhtyDXr+G0aZrj3L3NlEV7CobSqOdPyfo6G3d91WEZ5b8PyGm86IAreX08Jp9DChGXEd53gWysVpWCTs+w==}
'@tailwindcss/oxide-win32-x64-msvc@4.0.6':
resolution: {integrity: sha512-+o+juAkik4p8Ue/0LiflQXPmVatl6Av3LEZXpBTfg4qkMIbZdhCGWFzHdt2NjoMiLOJCFDddoV6GYaimvK1Olw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.0.5':
resolution: {integrity: sha512-iWGyOCu0TuzvCBisWbGv2K9+7QCfE0ztgtrZOvb9iF7V7ChVkD15Obe3HevZrhjngAc34jDA+OMSuSvkrpTy4A==}
'@tailwindcss/oxide@4.0.6':
resolution: {integrity: sha512-lVyKV2y58UE9CeKVcYykULe9QaE1dtKdxDEdrTPIdbzRgBk6bdxHNAoDqvcqXbIGXubn3VOl1O/CFF77v/EqSA==}
engines: {node: '>= 10'}
'@tailwindcss/postcss@4.0.5':
resolution: {integrity: sha512-U7IPb+KMASETtUvISwePM+1h+jLQspXf2ncfX/LmP/4AaH7b7DJQhqXzDCaJQd/MIh54dRUO93i9q4+Xm7dlVg==}
'@tailwindcss/postcss@4.0.6':
resolution: {integrity: sha512-noTaGPHjGCXTCc487TWnfAEN0VMjqDAecssWDOsfxV2hFrcZR0AHthX7IdY/0xHTg/EtpmIPdssddlZ5/B7JnQ==}
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
@ -1769,8 +1769,8 @@ packages:
'@upstash/redis@1.34.3':
resolution: {integrity: sha512-VT25TyODGy/8ljl7GADnJoMmtmJ1F8d84UXfGonRRF8fWYJz7+2J6GzW+a6ETGtk4OyuRTt7FRSvFG5GvrfSdQ==}
'@vercel/analytics@1.4.1':
resolution: {integrity: sha512-ekpL4ReX2TH3LnrRZTUKjHHNpNy9S1I7QmS+g/RQXoSUQ8ienzosuX7T9djZ/s8zPhBx1mpHP/Rw5875N+zQIQ==}
'@vercel/analytics@1.5.0':
resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==}
peerDependencies:
'@remix-run/react': ^2
'@sveltejs/kit': ^1 || ^2
@ -1803,8 +1803,8 @@ packages:
resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==}
engines: {node: '>=14.6'}
'@vercel/speed-insights@1.1.0':
resolution: {integrity: sha512-rAXxuhhO4mlRGC9noa5F7HLMtGg8YF1zAN6Pjd1Ny4pII4cerhtwSG4vympbCl+pWkH7nBS9kVXRD4FAn54dlg==}
'@vercel/speed-insights@1.2.0':
resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==}
peerDependencies:
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
@ -1885,8 +1885,8 @@ packages:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ai@4.1.26:
resolution: {integrity: sha512-Mww6mJbGwmMK0qAKR67WfVK1WyaUjfFlPZ2rhUUmDns3WhI+DVgMM7gLmuo0rA+I5qq69g7YE1OCgUwMRKKjMw==}
ai@4.1.34:
resolution: {integrity: sha512-9IB5duz6VbXvjibqNrvKz6++PwE8Ui5UfbOC9/CtcQN5Z9sudUQErss+maj7ptoPysD2NPjj99e0Hp183Cz5LQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -2378,10 +2378,6 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
enhanced-resolve@5.16.1:
resolution: {integrity: sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==}
engines: {node: '>=10.13.0'}
enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
@ -2445,8 +2441,8 @@ packages:
engines: {node: '>=6.0'}
hasBin: true
eslint-config-next@15.1.6:
resolution: {integrity: sha512-Wd1uy6y7nBbXUSg9QAuQ+xYEKli5CgUhLjz1QHW11jLDis5vK5XB3PemL6jEmy7HrdhaRFDz+GTZ/3FoH+EUjg==}
eslint-config-next@15.1.7:
resolution: {integrity: sha512-zXoMnYUIy3XHaAoOhrcYkT9UQWvXqWju2K7NNsmb5wd/7XESDwof61eUdW4QhERr3eJ9Ko/vnXqIrj8kk/drYw==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@ -2646,8 +2642,8 @@ packages:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
framer-motion@12.4.1:
resolution: {integrity: sha512-5Ijbea3topSZjadQ0hgc/TcWj2ldMZmNREM7RvAhvsThYOA1HHOA8TT1yKvMu1YXP3jWaFwoZ6Vo9Nw+DUZrzA==}
framer-motion@12.4.2:
resolution: {integrity: sha512-pW307cQKjDqEuO1flEoIFf6TkuJRfKr+c7qsHAJhDo4368N/5U8/7WU8J+xhd9+gjmOgJfgp+46evxRRFM39dA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
@ -3441,8 +3437,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.1.6:
resolution: {integrity: sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==}
next@15.1.7:
resolution: {integrity: sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -3661,8 +3657,8 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.1:
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
postcss@8.5.2:
resolution: {integrity: sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
@ -4088,8 +4084,8 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwindcss@4.0.5:
resolution: {integrity: sha512-DZZIKX3tA23LGTjHdnwlJOTxfICD1cPeykLLsYF1RQBI9QsCR3i0szohJfJDVjr6aNRAIio5WVO7FGB77fRHwg==}
tailwindcss@4.0.6:
resolution: {integrity: sha512-mysewHYJKaXgNOW6pp5xon/emCsfAMnO8WMaGKZZ35fomnR/T5gYnRg2/yRTTrtXiEl1tiVkeRt0eMO6HxEZqw==}
tapable@2.2.1:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
@ -4439,17 +4435,17 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.1.10(react@19.0.0)(zod@3.23.8)':
'@ai-sdk/react@1.1.11(react@19.0.0)(zod@3.23.8)':
dependencies:
'@ai-sdk/provider-utils': 2.1.6(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.10(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.11(zod@3.23.8)
swr: 2.3.2(react@19.0.0)
throttleit: 2.1.0
optionalDependencies:
react: 19.0.0
zod: 3.23.8
'@ai-sdk/ui-utils@1.1.10(zod@3.23.8)':
'@ai-sdk/ui-utils@1.1.11(zod@3.23.8)':
dependencies:
'@ai-sdk/provider': 1.0.7
'@ai-sdk/provider-utils': 2.1.6(zod@3.23.8)
@ -5512,41 +5508,41 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
'@next/bundle-analyzer@15.1.6':
'@next/bundle-analyzer@15.1.7':
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@next/env@15.1.6': {}
'@next/env@15.1.7': {}
'@next/eslint-plugin-next@15.1.6':
'@next/eslint-plugin-next@15.1.7':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.1.6':
'@next/swc-darwin-arm64@15.1.7':
optional: true
'@next/swc-darwin-x64@15.1.6':
'@next/swc-darwin-x64@15.1.7':
optional: true
'@next/swc-linux-arm64-gnu@15.1.6':
'@next/swc-linux-arm64-gnu@15.1.7':
optional: true
'@next/swc-linux-arm64-musl@15.1.6':
'@next/swc-linux-arm64-musl@15.1.7':
optional: true
'@next/swc-linux-x64-gnu@15.1.6':
'@next/swc-linux-x64-gnu@15.1.7':
optional: true
'@next/swc-linux-x64-musl@15.1.6':
'@next/swc-linux-x64-musl@15.1.7':
optional: true
'@next/swc-win32-arm64-msvc@15.1.6':
'@next/swc-win32-arm64-msvc@15.1.7':
optional: true
'@next/swc-win32-x64-msvc@15.1.6':
'@next/swc-win32-x64-msvc@15.1.7':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -6223,76 +6219,76 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tailwindcss/container-queries@0.1.1(tailwindcss@4.0.5)':
'@tailwindcss/container-queries@0.1.1(tailwindcss@4.0.6)':
dependencies:
tailwindcss: 4.0.5
tailwindcss: 4.0.6
'@tailwindcss/forms@0.5.10(tailwindcss@4.0.5)':
'@tailwindcss/forms@0.5.10(tailwindcss@4.0.6)':
dependencies:
mini-svg-data-uri: 1.4.4
tailwindcss: 4.0.5
tailwindcss: 4.0.6
'@tailwindcss/node@4.0.5':
'@tailwindcss/node@4.0.6':
dependencies:
enhanced-resolve: 5.18.1
jiti: 2.4.2
tailwindcss: 4.0.5
tailwindcss: 4.0.6
'@tailwindcss/oxide-android-arm64@4.0.5':
'@tailwindcss/oxide-android-arm64@4.0.6':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.0.5':
'@tailwindcss/oxide-darwin-arm64@4.0.6':
optional: true
'@tailwindcss/oxide-darwin-x64@4.0.5':
'@tailwindcss/oxide-darwin-x64@4.0.6':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.0.5':
'@tailwindcss/oxide-freebsd-x64@4.0.6':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.0.5':
'@tailwindcss/oxide-linux-arm-gnueabihf@4.0.6':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.0.5':
'@tailwindcss/oxide-linux-arm64-gnu@4.0.6':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.0.5':
'@tailwindcss/oxide-linux-arm64-musl@4.0.6':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.0.5':
'@tailwindcss/oxide-linux-x64-gnu@4.0.6':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.0.5':
'@tailwindcss/oxide-linux-x64-musl@4.0.6':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.0.5':
'@tailwindcss/oxide-win32-arm64-msvc@4.0.6':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.0.5':
'@tailwindcss/oxide-win32-x64-msvc@4.0.6':
optional: true
'@tailwindcss/oxide@4.0.5':
'@tailwindcss/oxide@4.0.6':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.0.5
'@tailwindcss/oxide-darwin-arm64': 4.0.5
'@tailwindcss/oxide-darwin-x64': 4.0.5
'@tailwindcss/oxide-freebsd-x64': 4.0.5
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.5
'@tailwindcss/oxide-linux-arm64-gnu': 4.0.5
'@tailwindcss/oxide-linux-arm64-musl': 4.0.5
'@tailwindcss/oxide-linux-x64-gnu': 4.0.5
'@tailwindcss/oxide-linux-x64-musl': 4.0.5
'@tailwindcss/oxide-win32-arm64-msvc': 4.0.5
'@tailwindcss/oxide-win32-x64-msvc': 4.0.5
'@tailwindcss/oxide-android-arm64': 4.0.6
'@tailwindcss/oxide-darwin-arm64': 4.0.6
'@tailwindcss/oxide-darwin-x64': 4.0.6
'@tailwindcss/oxide-freebsd-x64': 4.0.6
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.6
'@tailwindcss/oxide-linux-arm64-gnu': 4.0.6
'@tailwindcss/oxide-linux-arm64-musl': 4.0.6
'@tailwindcss/oxide-linux-x64-gnu': 4.0.6
'@tailwindcss/oxide-linux-x64-musl': 4.0.6
'@tailwindcss/oxide-win32-arm64-msvc': 4.0.6
'@tailwindcss/oxide-win32-x64-msvc': 4.0.6
'@tailwindcss/postcss@4.0.5':
'@tailwindcss/postcss@4.0.6':
dependencies:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.0.5
'@tailwindcss/oxide': 4.0.5
'@tailwindcss/node': 4.0.6
'@tailwindcss/oxide': 4.0.6
lightningcss: 1.29.1
postcss: 8.5.1
tailwindcss: 4.0.5
postcss: 8.5.2
tailwindcss: 4.0.6
'@testing-library/dom@10.4.0':
dependencies:
@ -6519,9 +6515,9 @@ snapshots:
dependencies:
crypto-js: 4.2.0
'@vercel/analytics@1.4.1(next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))':
'@vercel/analytics@1.5.0(next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))':
optionalDependencies:
next: 15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
svelte: 4.2.17
vue: 3.4.27(typescript@5.7.3)
@ -6538,9 +6534,9 @@ snapshots:
dependencies:
'@upstash/redis': 1.34.3
'@vercel/speed-insights@1.1.0(next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))':
'@vercel/speed-insights@1.2.0(next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.17)(vue@3.4.27(typescript@5.7.3))':
optionalDependencies:
next: 15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
svelte: 4.2.17
vue: 3.4.27(typescript@5.7.3)
@ -6569,7 +6565,7 @@ snapshots:
'@vue/shared': 3.4.27
estree-walker: 2.0.2
magic-string: 0.30.10
postcss: 8.5.1
postcss: 8.5.2
source-map-js: 1.2.1
optional: true
@ -6630,12 +6626,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
ai@4.1.26(react@19.0.0)(zod@3.23.8):
ai@4.1.34(react@19.0.0)(zod@3.23.8):
dependencies:
'@ai-sdk/provider': 1.0.7
'@ai-sdk/provider-utils': 2.1.6(zod@3.23.8)
'@ai-sdk/react': 1.1.10(react@19.0.0)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.10(zod@3.23.8)
'@ai-sdk/react': 1.1.11(react@19.0.0)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.11(zod@3.23.8)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
optionalDependencies:
@ -7160,11 +7156,6 @@ snapshots:
emoji-regex@9.2.2: {}
enhanced-resolve@5.16.1:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.1
enhanced-resolve@5.18.1:
dependencies:
graceful-fs: 4.2.11
@ -7285,9 +7276,9 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
eslint-config-next@15.1.6(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3):
eslint-config-next@15.1.7(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3):
dependencies:
'@next/eslint-plugin-next': 15.1.6
'@next/eslint-plugin-next': 15.1.7
'@rushstack/eslint-patch': 1.10.3
'@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)
'@typescript-eslint/parser': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)
@ -7315,7 +7306,7 @@ snapshots:
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.20.0(jiti@2.4.2)):
dependencies:
debug: 4.3.4
enhanced-resolve: 5.16.1
enhanced-resolve: 5.18.1
eslint: 9.20.0(jiti@2.4.2)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.20.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@9.20.0(jiti@2.4.2))
@ -7591,7 +7582,7 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
framer-motion@12.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
framer-motion@12.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
motion-dom: 12.0.0
motion-utils: 12.0.0
@ -8523,10 +8514,10 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.25(next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
next-auth@5.0.0-beta.25(next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
dependencies:
'@auth/core': 0.37.2
next: 15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
next-themes@0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
@ -8534,9 +8525,9 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
next@15.1.6(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.1.7(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.1.6
'@next/env': 15.1.7
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -8546,14 +8537,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(@babel/core@7.24.5)(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.1.6
'@next/swc-darwin-x64': 15.1.6
'@next/swc-linux-arm64-gnu': 15.1.6
'@next/swc-linux-arm64-musl': 15.1.6
'@next/swc-linux-x64-gnu': 15.1.6
'@next/swc-linux-x64-musl': 15.1.6
'@next/swc-win32-arm64-msvc': 15.1.6
'@next/swc-win32-x64-msvc': 15.1.6
'@next/swc-darwin-arm64': 15.1.7
'@next/swc-darwin-x64': 15.1.7
'@next/swc-linux-arm64-gnu': 15.1.7
'@next/swc-linux-arm64-musl': 15.1.7
'@next/swc-linux-x64-gnu': 15.1.7
'@next/swc-linux-x64-musl': 15.1.7
'@next/swc-win32-arm64-msvc': 15.1.7
'@next/swc-win32-x64-msvc': 15.1.7
'@opentelemetry/api': 1.9.0
sharp: 0.33.5
transitivePeerDependencies:
@ -8756,7 +8747,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.1:
postcss@8.5.2:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
@ -8958,7 +8949,7 @@ snapshots:
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.1
postcss: 8.5.2
sax@1.2.4: {}
@ -9217,7 +9208,7 @@ snapshots:
symbol-tree@3.2.4: {}
tailwindcss@4.0.5: {}
tailwindcss@4.0.6: {}
tapable@2.2.1: {}

View File

@ -4,7 +4,7 @@ import ErrorNote from '@/components/ErrorNote';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import Container from '@/components/Container';
import { addAllUploadsAction } from '@/photo/actions';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS } from '@/app-core/paths';
import { Tags } from '@/tag';
import {
generateLocalNaivePostgresString,

View File

@ -0,0 +1,20 @@
import { Suspense } from 'react';
import { APP_CONFIGURATION } from '@/app-core/config';
import AdminAppConfigurationServer from './AdminAppConfigurationServer';
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
export default function AdminAppConfiguration({
simplifiedView,
}: {
simplifiedView?: boolean
}) {
return (
<Suspense fallback={<AdminAppConfigurationClient {...{
...APP_CONFIGURATION,
isAnalyzingConfiguration: true,
simplifiedView,
}} /> }>
<AdminAppConfigurationServer {...{ simplifiedView }} />
</Suspense>
);
}

View File

@ -4,18 +4,16 @@ import {
ComponentProps,
ReactNode,
} from 'react';
import { clsx } from 'clsx/lite';
import ChecklistRow from '../components/ChecklistRow';
import { FiExternalLink } from 'react-icons/fi';
import {
BiCog,
BiData,
BiHide,
BiLockAlt,
BiPencil,
} from 'react-icons/bi';
import Checklist from '@/components/Checklist';
import { ConfigChecklistStatus } from './config';
import { HiOutlineCog } from 'react-icons/hi';
import ChecklistGroup from '@/components/ChecklistGroup';
import { ConfigChecklistStatus } from '../app-core/config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
import { HiSparkles } from 'react-icons/hi';
@ -23,14 +21,14 @@ import { testConnectionsAction } from '@/admin/actions';
import ErrorNote from '@/components/ErrorNote';
import WarningNote from '@/components/WarningNote';
import { RiSpeedMiniLine } from 'react-icons/ri';
import Link from 'next/link';
import SecretGenerator from './SecretGenerator';
import CopyButton from '@/components/CopyButton';
import SecretGenerator from '../app-core/SecretGenerator';
import { PiPaintBrushHousehold } from 'react-icons/pi';
import { IoMdGrid } from 'react-icons/io';
import { CgDebug } from 'react-icons/cg';
import EnvVar from '@/components/EnvVar';
import AdminLink from './AdminLink';
export default function SiteChecklistClient({
export default function AdminAppConfigurationClient({
// Storage
hasDatabase,
isPostgresSslEnabled,
@ -79,7 +77,7 @@ export default function SiteChecklistClient({
isGridHomepageEnabled,
gridAspectRatio,
hasGridAspectRatio,
gridDensity,
hasHighGridDensity,
hasGridDensityPreference,
// Settings
isGeoPrivacyEnabled,
@ -94,9 +92,6 @@ export default function SiteChecklistClient({
isAdminSqlDebugEnabled,
// Misc
baseUrl,
commitSha,
commitMessage,
commitUrl,
// Connection status
databaseError,
storageError,
@ -110,54 +105,10 @@ export default function SiteChecklistClient({
simplifiedView?: boolean
isAnalyzingConfiguration?: boolean
}) {
const renderLink = (href: string, text: string, external = true) =>
<>
<a {...{
href,
...external && { target: '_blank', rel: 'noopener noreferrer' },
className: clsx(
'underline hover:no-underline',
),
}}>
{text}
</a>
{external &&
<>
&nbsp;
<FiExternalLink
size={14}
className='inline translate-y-[-1.5px]'
/>
</>}
</>;
const renderEnvVar = (
variable: string,
minimal?: boolean,
) =>
<div
key={variable}
className={clsx(
'overflow-x-auto overflow-y-hidden',
minimal && 'inline-flex',
)}
>
<span className="inline-flex items-center gap-1">
<span className={clsx(
'text-[11px] font-medium tracking-wider',
'px-0.5 py-[0.5px]',
'rounded-[5px]',
'bg-gray-100 dark:bg-gray-800',
)}>
`{variable}`
</span>
{!minimal && <CopyButton label={variable} text={variable} subtle />}
</span>
</div>;
const renderEnvVars = (variables: string[]) =>
<div className="pt-1 space-y-1">
{variables.map(envVar => renderEnvVar(envVar))}
<div className="pt-1 flex flex-col gap-1">
{variables.map(envVar =>
<EnvVar key={envVar} variable={envVar} />)}
</div>;
const renderSubStatus = (
@ -181,7 +132,7 @@ export default function SiteChecklistClient({
renderSubStatus(
type,
renderEnvVars([variable]),
'translate-y-[5px]',
'translate-y-[3px]',
);
const renderError = ({
@ -213,9 +164,9 @@ export default function SiteChecklistClient({
</WarningNote>;
return (
<div className="max-w-xl w-full">
<>
<div className="space-y-3 -mt-3">
<Checklist
<ChecklistGroup
title="Storage"
icon={<BiData size={16} />}
>
@ -234,11 +185,13 @@ export default function SiteChecklistClient({
: renderSubStatus('optional', <>
Vercel Postgres:
{' '}
{renderLink(
<AdminLink
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-postgres/quickstart#create-a-postgres-database',
'create store',
)}
href="https://vercel.com/docs/storage/vercel-postgres/quickstart#create-a-postgres-database"
externalIcon
>
create store
</AdminLink>
{' '}
and connect to project
</>)}
@ -270,11 +223,13 @@ export default function SiteChecklistClient({
: renderSubStatus('optional', <>
{labelForStorage('vercel-blob')}:
{' '}
{renderLink(
<AdminLink
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store',
'create store',
)}
href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store"
externalIcon
>
create store
</AdminLink>
{' '}
and connect to project
</>,
@ -284,24 +239,29 @@ export default function SiteChecklistClient({
: renderSubStatus('optional', <>
{labelForStorage('cloudflare-r2')}:
{' '}
{renderLink(
'https://github.com/sambecker/exif-photo-blog#cloudflare-r2',
'create/configure bucket',
)}
<AdminLink
// eslint-disable-next-line max-len
href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2"
externalIcon
>
create/configure bucket
</AdminLink>
</>)}
{hasAwsS3Storage
? renderSubStatus('checked', 'AWS S3: connected')
: renderSubStatus('optional', <>
{labelForStorage('aws-s3')}:
{' '}
{renderLink(
'https://github.com/sambecker/exif-photo-blog#aws-s3',
'create/configure bucket',
)}
<AdminLink
href="https://github.com/sambecker/exif-photo-blog#aws-s3"
externalIcon
>
create/configure bucket
</AdminLink>
</>)}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Authentication"
icon={<BiLockAlt size={16} />}
>
@ -331,8 +291,8 @@ export default function SiteChecklistClient({
'ADMIN_PASSWORD',
])}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Content"
icon={<BiPencil size={16} />}
>
@ -373,9 +333,9 @@ export default function SiteChecklistClient({
Store in environment variable (seen in grid sidebar):
{renderEnvVars(['NEXT_PUBLIC_SITE_ABOUT'])}
</ChecklistRow>
</Checklist>
</ChecklistGroup>
{!simplifiedView && <>
<Checklist
<ChecklistGroup
title="AI text generation"
titleShort="AI"
icon={<HiSparkles />}
@ -408,11 +368,13 @@ export default function SiteChecklistClient({
{kvError && renderError({
connection: { provider: 'Vercel KV', error: kvError},
})}
{renderLink(
<AdminLink
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
'Create Vercel KV store',
)}
href="https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database"
externalIcon
>
Create Vercel KV store
</AdminLink>
{' '}
and connect to project in order to enable rate limiting
</ChecklistRow>
@ -429,8 +391,8 @@ export default function SiteChecklistClient({
(default: {'"title, tags, semantic"'}):
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Performance"
icon={<RiSpeedMiniLine size={18} />}
optional
@ -490,8 +452,8 @@ export default function SiteChecklistClient({
image blur data being stored and displayed:
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Visual"
icon={<PiPaintBrushHousehold size={19} />}
optional
@ -518,8 +480,8 @@ export default function SiteChecklistClient({
of each photo, and display a surrounding border:
{renderEnvVars(['NEXT_PUBLIC_MATTE_PHOTOS'])}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Display"
icon={<BiHide size={18} />}
optional
@ -578,8 +540,8 @@ export default function SiteChecklistClient({
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Grid"
icon={<IoMdGrid size={17} />}
optional
@ -604,7 +566,7 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
</ChecklistRow>
<ChecklistRow
title={`Grid density: ${gridDensity ? 'low' : 'high'}`}
title={`Grid density: ${hasHighGridDensity ? 'high' : 'low'}`}
status={hasGridDensityPreference}
optional
>
@ -613,10 +575,10 @@ export default function SiteChecklistClient({
aspect ratio):
{renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])}
</ChecklistRow>
</Checklist>
<Checklist
</ChecklistGroup>
<ChecklistGroup
title="Settings"
icon={<BiCog size={16} />}
icon={<HiOutlineCog size={17} className="translate-y-[0.5px]" />}
optional
>
<ChecklistRow
@ -664,9 +626,9 @@ export default function SiteChecklistClient({
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</Checklist>
</ChecklistGroup>
{areInternalToolsEnabled &&
<Checklist
<ChecklistGroup
title="Internal"
icon={<CgDebug size={16} />}
optional
@ -698,7 +660,7 @@ export default function SiteChecklistClient({
console output for all sql queries:
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
</ChecklistRow>
</Checklist>}
</ChecklistGroup>}
</>}
</div>
<div className="pl-11 pr-2 sm:pr-11 mt-4 md:mt-7">
@ -715,23 +677,8 @@ export default function SiteChecklistClient({
{baseUrl || 'Not Defined'}
</span>
</div>
<div>
<span className="font-bold">Commit</span>
&nbsp;&nbsp;
{commitSha
? commitUrl
? <Link
title={commitMessage}
href={commitUrl}
target="_blank"
>
{commitSha}
</Link>
: <span title={commitMessage}>{commitSha}</span>
: 'Not Found'}
</div>
</div>}
</div>
</div>
</>
);
}

View File

@ -1,8 +1,8 @@
import SiteChecklistClient from './SiteChecklistClient';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
import { APP_CONFIGURATION } from '@/app-core/config';
import { testConnectionsAction } from '@/admin/actions';
export default async function SiteChecklistServer({
export default async function AdminAppConfigurationServer({
simplifiedView,
}: {
simplifiedView?: boolean
@ -10,8 +10,8 @@ export default async function SiteChecklistServer({
const connectionErrors = await testConnectionsAction().catch(() => ({}));
return (
<SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS,
<AdminAppConfigurationClient {...{
...APP_CONFIGURATION,
...connectionErrors,
simplifiedView,
}} />

View File

@ -1,11 +1,16 @@
'use client';
import MoreMenu from '@/components/more/MoreMenu';
import { PATH_ADMIN_CONFIGURATION, PATH_GRID_INFERRED } from '@/site/paths';
import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
PATH_GRID_INFERRED,
} from '@/app-core/paths';
import { useAppState } from '@/state/AppState';
import { BiCog } from 'react-icons/bi';
import { ImCheckboxUnchecked } from 'react-icons/im';
import { IoCloseSharp } from 'react-icons/io5';
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
import { LuCog } from 'react-icons/lu';
export default function AdminAppMenu() {
const {
@ -18,19 +23,27 @@ export default function AdminAppMenu() {
return (
<MoreMenu
items={[{
label: 'App Config',
icon: <BiCog className="text-[17px]" />,
label: 'Insights',
icon: <span className="scale-90 translate-y-[-2px]">
<AdminAppInsightsIcon />
</span>,
href: PATH_ADMIN_INSIGHTS,
}, {
label: 'Configuration',
icon: <LuCog
className="text-[16px] translate-x-[0.5px]"
/>,
href: PATH_ADMIN_CONFIGURATION,
}, {
label: isSelecting
? 'Exit Select'
: 'Select Multiple',
: 'Select',
icon: isSelecting
? <IoCloseSharp
className="text-[18px] translate-y-[-0.5px]"
/>
: <ImCheckboxUnchecked
className="text-[0.75rem]"
className="text-[0.75rem] translate-x-[0.5px]"
/>,
href: PATH_GRID_INFERRED,
action: () => {

View File

@ -9,7 +9,7 @@ import { IoCloseSharp } from 'react-icons/io5';
import { useState } from 'react';
import { TAG_FAVS, Tags } from '@/tag';
import { usePathname } from 'next/navigation';
import { PATH_GRID_INFERRED } from '@/site/paths';
import { PATH_GRID_INFERRED } from '@/app-core/paths';
import PhotoTagFieldset from './PhotoTagFieldset';
import { tagMultiplePhotosAction } from '@/photo/actions';
import { toastSuccess } from '@/toast';

View File

@ -1,7 +1,7 @@
'use client';
import PhotoUpload from '@/photo/PhotoUpload';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS } from '@/app-core/paths';
import { useAppState } from '@/state/AppState';
import Link from 'next/link';
import { useState } from 'react';

View File

@ -0,0 +1,29 @@
import clsx from 'clsx/lite';
import { ReactNode } from 'react';
import { IoInformationCircleOutline } from 'react-icons/io5';
export default function AdminEmptyState({
icon,
children,
includeContainer = true,
}: {
icon?: ReactNode
children: ReactNode
includeContainer?: boolean
}) {
return (
<div className={clsx(
'flex flex-col gap-4 justify-center items-center p-8',
includeContainer &&'component-surface shadow-xs',
)}>
<div className={clsx(
'size-14 flex justify-center items-center',
'text-[1.75rem] text-medium',
'border border-main rounded-xl shadow-xs',
)}>
{icon ?? <IoInformationCircleOutline />}
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,32 @@
import Container from '@/components/Container';
import SiteGrid from '@/components/SiteGrid';
import { ReactNode } from 'react';
export default function AdminInfoPage({
title,
accessory,
children,
}: {
title: string
accessory?: ReactNode
children: ReactNode
}) {
return (
<SiteGrid
contentMain={
<div className="space-y-4">
<div className="flex items-center gap-4 min-h-9">
<div className="grow">
{title}
</div>
{accessory}
</div>
<Container spaceChildren={false}>
<div className="max-w-xl w-full">
{children}
</div>
</Container>
</div>}
/>
);
}

38
src/admin/AdminLink.tsx Normal file
View File

@ -0,0 +1,38 @@
import clsx from 'clsx/lite';
import Link from 'next/link';
import { ComponentProps } from 'react';
import { FiExternalLink } from 'react-icons/fi';
export default function AdminLink({
href,
className,
children,
externalIcon,
...props
}: ComponentProps<typeof Link> & {
externalIcon?: boolean
}) {
return (
<>
<Link
{...props}
href={href}
target="blank"
className={clsx(
'underline underline-offset-4',
'decoration-gray-300 dark:decoration-gray-700',
className,
)}
>
{children}
</Link>
{externalIcon &&
<>
&nbsp;
<FiExternalLink
size={14}
className="inline translate-y-[-1.5px]"
/>
</>}
</>
);
}

View File

@ -8,7 +8,7 @@ import {
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
} from '@/site/paths';
} from '@/app-core/paths';
import AdminNavClient from './AdminNavClient';
export default async function AdminNav() {
@ -31,6 +31,8 @@ export default async function AdminNav() {
getPhotosMostRecentUpdateCached().catch(() => undefined),
]);
const includeInsights = countPhotos > 0;
// Photos
const items = [{
label: 'Photos',
@ -53,6 +55,10 @@ export default async function AdminNav() {
}); }
return (
<AdminNavClient {...{ items, mostRecentPhotoUpdateTime }} />
<AdminNavClient {...{
items,
mostRecentPhotoUpdateTime,
includeInsights,
}} />
);
}

View File

@ -7,17 +7,20 @@ import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner';
import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
checkPathPrefix,
isPathAdminConfiguration,
isPathAdminInsights,
isPathTopLevelAdmin,
} from '@/site/paths';
} from '@/app-core/paths';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { differenceInMinutes } from 'date-fns';
import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { BiCog } from 'react-icons/bi';
import { FaRegClock } from 'react-icons/fa';
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
import { LuCog } from 'react-icons/lu';
// Updates considered recent if they occurred in past 5 minutes
const areTimesRecent = (dates: Date[]) => dates
@ -26,6 +29,7 @@ const areTimesRecent = (dates: Date[]) => dates
export default function AdminNavClient({
items,
mostRecentPhotoUpdateTime,
includeInsights,
}: {
items: {
label: string,
@ -33,6 +37,7 @@ export default function AdminNavClient({
count: number,
}[]
mostRecentPhotoUpdateTime?: Date
includeInsights?: boolean
}) {
const pathname = usePathname();
@ -86,19 +91,33 @@ export default function AdminNavClient({
<span>({count})</span>}
</LinkWithStatus>)}
</div>
<LinkWithLoader
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
loader={<Spinner />}
>
<BiCog
size={18}
className="inline-flex translate-y-0.5"
aria-label="App Configuration"
/>
</LinkWithLoader>
<div className="flex gap-3">
{includeInsights &&
<LinkWithLoader
href={PATH_ADMIN_INSIGHTS}
className={clsx(
'translate-y-[-2px]',
isPathAdminInsights(pathname)
? 'font-bold'
: 'text-dim')}
loader={<Spinner className="translate-y-[1px]" />}
>
<AdminAppInsightsIcon />
</LinkWithLoader>}
<LinkWithLoader
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
loader={<Spinner className="translate-y-[-0.75px]" />}
>
<LuCog
size={20}
className="inline-flex translate-y-[1px]"
aria-label="App Configuration"
/>
</LinkWithLoader>
</div>
</div>
{shouldShowBanner &&
<Note icon={<FaRegClock className="shrink-0" />}>

View File

@ -3,10 +3,10 @@
import { OUTDATED_THRESHOLD, Photo } from '@/photo';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import LoaderButton from '@/components/primitives/LoaderButton';
import IconGrSync from '@/site/IconGrSync';
import IconGrSync from '@/app-core/IconGrSync';
import Note from '@/components/Note';
import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS } from '@/app-core/paths';
import { useState } from 'react';
import { syncPhotosAction } from '@/photo/actions';
import { useRouter } from 'next/navigation';
@ -67,6 +67,7 @@ export default function AdminOutdatedClient({
}
}}
isLoading={arePhotoIdsSyncing}
disabled={!updateBatchSize}
>
{arePhotoIdsSyncing
? 'Syncing'
@ -78,6 +79,9 @@ export default function AdminOutdatedClient({
<div className="space-y-6">
<Note>
<div className="space-y-1.5">
<div className="font-bold">
Outdated photos found
</div>
{photos.length}
{' '}
{photos.length === 1 ? 'photo' : 'photos'}
@ -87,8 +91,7 @@ export default function AdminOutdatedClient({
{' '}
may have: missing EXIF fields, inaccurate blur data,
{' '}
undesired privacy settings
{hasAiTextGeneration && ', missing AI-generated text'}
undesired privacy settings, or missing AI-generated text
</div>
</Note>
<div className="space-y-4">

View File

@ -1,7 +1,7 @@
'use client';
import { ComponentProps, useMemo } from 'react';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app-core/paths';
import { deletePhotoAction, toggleFavoritePhotoAction } from '@/photo/actions';
import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa';
import {

View File

@ -6,11 +6,11 @@ import SiteGrid from '@/components/SiteGrid';
import {
AI_TEXT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/site/config';
} from '@/app-core/config';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
import { PATH_ADMIN_OUTDATED } from '@/site/paths';
import { PATH_ADMIN_OUTDATED } from '@/app-core/paths';
import { Photo } from '@/photo';
import { StorageListResponse } from '@/services/storage';
import { useState } from 'react';

View File

@ -5,7 +5,7 @@ import AdminTable from './AdminTable';
import { Fragment } from 'react';
import PhotoSmall from '@/photo/PhotoSmall';
import { clsx } from 'clsx/lite';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app-core/paths';
import Link from 'next/link';
import { AiOutlineEyeInvisible } from 'react-icons/ai';
import PhotoDate from '@/photo/PhotoDate';

View File

@ -1,6 +1,6 @@
'use client';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS } from '@/app-core/paths';
import InfinitePhotoScroll from '../photo/InfinitePhotoScroll';
import AdminPhotosTable from './AdminPhotosTable';
import { ComponentProps } from 'react';

View File

@ -6,7 +6,7 @@ import DeleteFormButton from '@/admin/DeleteFormButton';
import { photoQuantityText } from '@/photo';
import { Tags, formatTag, sortTagsObject } from '@/tag';
import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/site/paths';
import { pathForAdminTagEdit } from '@/app-core/paths';
import { clsx } from 'clsx/lite';
import AdminTagBadge from './AdminTagBadge';

View File

@ -5,7 +5,7 @@ import Spinner from '@/components/Spinner';
import { getIdFromStorageUrl } from '@/services/storage';
import { clsx } from 'clsx/lite';
import { FaRegCircleCheck } from 'react-icons/fa6';
import { pathForAdminUploadUrl } from '@/site/paths';
import { pathForAdminUploadUrl } from '@/app-core/paths';
import AddButton from './AddButton';
import { UrlAddStatus } from './AdminUploadsClient';
import ResponsiveDate from '@/components/ResponsiveDate';

View File

@ -3,7 +3,7 @@
import { deleteUploadAction } from '@/photo/actions';
import DeleteButton from './DeleteButton';
import { useRouter } from 'next/navigation';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS } from '@/app-core/paths';
import { useState } from 'react';
export default function DeleteUploadButton({

View File

@ -4,7 +4,7 @@ import LoaderButton from '@/components/primitives/LoaderButton';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { getExifDataAction } from '@/photo/actions';
import { PhotoFormData } from '@/photo/form';
import IconGrSync from '@/site/IconGrSync';
import IconGrSync from '@/app-core/IconGrSync';
import { clsx } from 'clsx/lite';
import { ComponentProps, useState } from 'react';

View File

@ -1,6 +1,6 @@
import LoaderButton from '@/components/primitives/LoaderButton';
import { syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/site/IconGrSync';
import IconGrSync from '@/app-core/IconGrSync';
import { toastSuccess } from '@/toast';
import { ComponentProps, useState } from 'react';

View File

@ -5,7 +5,7 @@ import { testKvConnection } from '@/services/kv';
import { testOpenAiConnection } from '@/services/openai';
import { testDatabaseConnection } from '@/services/postgres';
import { testStorageConnection } from '@/services/storage';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
import { APP_CONFIGURATION } from '@/app-core/config';
const scanForError = (
shouldCheck: boolean,
@ -24,7 +24,7 @@ export const testConnectionsAction = async () =>
hasStorageProvider,
hasVercelKv,
isAiTextGenerationEnabled,
} = CONFIG_CHECKLIST_STATUS;
} = APP_CONFIGURATION;
const [
databaseError,

View File

@ -1,15 +0,0 @@
import { Suspense } from 'react';
import GitHubForkStatusBadgeClient from './GitHubForkStatusBadgeClient';
import GitHubForkStatusBadgeServer from './GitHubForkStatusBadgeServer';
import { IS_DEVELOPMENT } from '@/site/config';
export default function GitHubForkStatusBadge() {
return IS_DEVELOPMENT
? <GitHubForkStatusBadgeClient
label="Local"
tooltip="GitHub status unknown when running locally."
/>
: <Suspense>
<GitHubForkStatusBadgeServer />
</Suspense>;
}

View File

@ -1,65 +0,0 @@
import Spinner from '@/components/Spinner';
import Tooltip from '@/components/Tooltip';
import clsx from 'clsx/lite';
import { ReactNode } from 'react';
import { BiLogoGithub } from 'react-icons/bi';
export default function GitHubForkStatusBadgeClient({
label,
style = 'mono',
tooltip,
}: {
label?: ReactNode
style?: 'success' | 'warning' | 'error' | 'mono'
tooltip?: ReactNode
}) {
const classNameForStyle = () => {
switch (style) {
case 'success': return clsx(
'text-green-700 hover:text-green-700',
'dark:text-green-400 dark:hover:text-green-400',
'bg-green-100/40 dark:bg-green-900/25',
'border-green-300/40 dark:border-green-900/50',
);
case 'warning': return clsx(
'text-amber-800/90 hover:text-amber-800/90',
'dark:text-amber-400 dark:hover:text-amber-400',
'bg-amber-100/40 dark:bg-amber-900/25',
'border-amber-300/40 dark:border-amber-900/50',
);
case 'error': return clsx(
'text-red-700/90 hover:text-red-700/90',
'dark:text-red-400 dark:hover:text-red-400',
'bg-red-100/20 dark:bg-red-900/25',
'border-red-300/40 dark:border-red-900/50',
);
default: return clsx(
'text-main',
'bg-white dark:bg-transparent',
'border-main',
);
}
};
return (
<Tooltip content={tooltip}>
<div className={clsx(
'opacity-0 transition-opacity animate-fade-in',
'inline-flex items-center gap-2',
'border transition-colors',
'select-none',
'pl-[4.5px] pr-2.5 py-[3px]',
'rounded-full shadow-xs',
classNameForStyle(),
)}>
{!label
? <Spinner
color="text"
className="translate-x-[3px]"
/>
: <BiLogoGithub size={17} />}
{label ?? 'Checking'}
</div>
</Tooltip>
);
}

View File

@ -1,62 +0,0 @@
import GitHubForkStatusBadgeClient from './GitHubForkStatusBadgeClient';
import {
VERCEL_GIT_BRANCH,
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
VERCEL_GIT_COMMIT_SHA,
} from '@/site/config';
import { getGitHubMetaWithFallback, getGitHubRepoUrl } from '.';
export default async function GitHubForkStatusBadgeServer() {
const owner = VERCEL_GIT_REPO_OWNER;
const repo = VERCEL_GIT_REPO_SLUG;
const branch = VERCEL_GIT_BRANCH;
const commit = VERCEL_GIT_COMMIT_SHA;
const {
url,
isForkedFromBase,
isBaseRepo,
isBehind,
label,
description,
didError,
} = await getGitHubMetaWithFallback({ owner, repo, branch, commit });
const repoLink = (text: string) =>
<a
href={getGitHubRepoUrl({ owner, repo })}
target="_blank"
className="underline hover:no-underline hover:text-main"
>
{text}
</a>;
const isBehindContent = <>
{' '}
{repoLink('Sync on GitHub')} for latest updates.
</>;
const didErrorContent = <>
{' '}
Could not connect to {repoLink('GitHub')}.
</>;
return isForkedFromBase || isBaseRepo
? <GitHubForkStatusBadgeClient {...{
url,
label,
tooltip: <>
{description}
{didError
? didErrorContent
: isBehind
? isBehindContent
: null}
</>,
style: didError || isBehind === undefined || isBehind
? 'warning'
: 'mono',
}} />
: null;
}

View File

@ -1,172 +0,0 @@
import {
TEMPLATE_BASE_OWNER,
TEMPLATE_BASE_REPO,
TEMPLATE_BASE_BRANCH,
} from '@/site/config';
const DEFAULT_BRANCH = 'main';
const FALLBACK_TEXT = 'Unknown';
const CACHE_GITHUB_REQUESTS = false;
// Cache all results for 2 minutes to avoid rate limiting
// GitHub API requests limited to 60 requests per hour
const FETCH_CONFIG: RequestInit | undefined= CACHE_GITHUB_REQUESTS
? { next: { revalidate: 120 } } : undefined;
interface RepoParams {
owner?: string
repo?: string
branch?: string
commit?: string
};
// Website urls
export const getGitHubRepoUrl = ({
owner = TEMPLATE_BASE_OWNER,
repo = TEMPLATE_BASE_REPO,
}: RepoParams = {}) =>
`https://github.com/${owner}/${repo}`;
export const getGitHubCompareUrl = ({
owner,
repo,
branch = DEFAULT_BRANCH,
}: RepoParams = {}) =>
// eslint-disable-next-line max-len
`${getGitHubRepoUrl({ owner, repo })}/compare/${branch}...${TEMPLATE_BASE_OWNER}:${TEMPLATE_BASE_REPO}:${TEMPLATE_BASE_BRANCH}`;
// API urls
const getGitHubApiRepoUrl = ({
owner = TEMPLATE_BASE_OWNER,
repo = TEMPLATE_BASE_REPO,
}: RepoParams = {}) =>
`https://api.github.com/repos/${owner}/${repo}`;
const getGitHubApiCommitsUrl = (params?: RepoParams) =>
`${getGitHubApiRepoUrl(params)}/commits/${params?.branch || DEFAULT_BRANCH}`;
const getGitHubApiForksUrl = (params?: RepoParams) =>
`${getGitHubApiRepoUrl(params)}/forks`;
const getGitHubApiCompareToRepoUrl = ({
owner,
repo,
branch = DEFAULT_BRANCH,
}: RepoParams = {}) =>
// eslint-disable-next-line max-len
`${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${owner}:${repo}:${branch}`;
const getGitHubApiCompareToCommitUrl = ({ commit }: RepoParams = {}) =>
`${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${commit}`;
// Requests
export const getLatestBaseRepoCommitSha = async () => {
const response = await fetch(getGitHubApiCommitsUrl(), FETCH_CONFIG);
const data = await response.json();
return data.sha ? data.sha.slice(0, 7) as string : undefined;
};
const getIsRepoForkedFromBase = async (params: RepoParams) => {
const response = await fetch(getGitHubApiRepoUrl(params), FETCH_CONFIG);
const data = await response.json();
return (
Boolean(data.fork) &&
data.source?.full_name === `${TEMPLATE_BASE_OWNER}/${TEMPLATE_BASE_REPO}`
);
};
const getGitHubCommitsBehindFromRepo = async (params?: RepoParams) => {
const response = await fetch(
getGitHubApiCompareToRepoUrl(params),
FETCH_CONFIG,
);
const data = await response.json();
return data.behind_by as number;
};
const getGitHubCommitsBehindFromCommit = async (params?: RepoParams) => {
const response = await fetch(
getGitHubApiCompareToCommitUrl(params),
FETCH_CONFIG,
);
const data = await response.json();
return data.behind_by as number;
};
const isRepoBaseRepo = ({ owner, repo }: RepoParams) =>
owner?.toLowerCase() === TEMPLATE_BASE_OWNER &&
repo?.toLowerCase() === TEMPLATE_BASE_REPO;
export const getGitHubPublicFork = async (
params?: RepoParams,
): Promise<RepoParams> => {
const response = await fetch(getGitHubApiForksUrl(params), FETCH_CONFIG);
const fork = (await response.json())[0];
return {
owner: fork.owner.login,
repo: fork.name,
};
};
const getGitHubMeta = async (params: RepoParams) => {
const url = getGitHubRepoUrl(params);
const isBaseRepo = isRepoBaseRepo(params);
const [
isForkedFromBase,
behindBy,
] = await Promise.all([
getIsRepoForkedFromBase(params),
isBaseRepo && params.commit
? getGitHubCommitsBehindFromCommit(params)
: getGitHubCommitsBehindFromRepo(params),
]);
const isBehind = behindBy === undefined
? undefined
: behindBy > 0;
const label = isBehind === undefined
? FALLBACK_TEXT
: isBehind
? `${behindBy} Behind`
: 'Synced';
const description = isBehind === undefined
? FALLBACK_TEXT
: isBehind
? `This fork is ${behindBy} commit${behindBy === 1 ? '' : 's'} behind.`
: isBaseRepo
? 'This build is up to date.'
: 'This fork is up to date.';
return {
url,
isForkedFromBase,
isBaseRepo,
behindBy,
isBehind,
label,
description,
didError: false,
};
};
export const getGitHubMetaWithFallback = (params: RepoParams) =>
getGitHubMeta(params)
.catch(e => {
console.error('Error retrieving GitHub meta', { params, error: e });
return {
url: undefined,
isForkedFromBase: false,
isBaseRepo: undefined,
behindBy: undefined,
isBehind: undefined,
label: FALLBACK_TEXT,
description: 'Could not connect to GitHub.',
didError: true,
};
});

View File

@ -0,0 +1,96 @@
import {
getPhotosMeta,
getUniqueCameras,
getUniqueFilmSimulations,
getUniqueLenses,
getUniqueTags,
} from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient';
import {
APP_CONFIGURATION,
GRID_HOMEPAGE_ENABLED,
HAS_STATIC_OPTIMIZATION,
IS_DEVELOPMENT,
IS_PRODUCTION,
IS_VERCEL_GIT_PROVIDER_GITHUB,
MATTE_PHOTOS,
VERCEL_GIT_BRANCH,
VERCEL_GIT_COMMIT_SHA,
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
} from '@/app-core/config';
import { getGitHubMeta } from '../../platforms/github';
import { OUTDATED_THRESHOLD } from '@/photo';
const BASIC_PHOTO_INSTALLATION_COUNT = 32;
const owner = VERCEL_GIT_REPO_OWNER;
const repo = VERCEL_GIT_REPO_SLUG;
const branch = VERCEL_GIT_BRANCH;
const commit = VERCEL_GIT_COMMIT_SHA;
export default async function AdminAppInsights() {
const [
{ count: photosCount, dateRange },
{ count: photosCountHidden },
{ count: photosCountOutdated },
{ count: photosCountPortrait },
tags,
cameras,
filmSimulations,
lenses,
codeMeta,
] = await Promise.all([
getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }),
getPhotosMeta({ hidden: 'include', updatedBefore: OUTDATED_THRESHOLD }),
getPhotosMeta({ maximumAspectRatio: 0.9 }),
getUniqueTags(),
getUniqueCameras(),
getUniqueFilmSimulations(),
getUniqueLenses(),
IS_VERCEL_GIT_PROVIDER_GITHUB || IS_DEVELOPMENT
? getGitHubMeta({
owner,
repo,
branch,
commit,
})
: undefined,
]);
const {
isAiTextGenerationEnabled,
hasVercelBlobStorage,
} = APP_CONFIGURATION;
return (
<AdminAppInsightsClient
codeMeta={codeMeta}
insights={{
noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo,
forkBehind: Boolean(codeMeta?.isBehind),
noAi: !isAiTextGenerationEnabled,
noAiRateLimiting: isAiTextGenerationEnabled && !hasVercelBlobStorage,
outdatedPhotos: Boolean(photosCountOutdated),
photoMatting: photosCountPortrait > 0 && !MATTE_PHOTOS,
gridFirst: (
photosCount >= BASIC_PHOTO_INSTALLATION_COUNT &&
!GRID_HOMEPAGE_ENABLED
),
noStaticOptimization: !HAS_STATIC_OPTIMIZATION,
}}
photoStats={{
photosCount,
photosCountHidden,
photosCountOutdated,
tagsCount: tags.length,
camerasCount: cameras.length,
filmSimulationsCount: filmSimulations.length,
lensesCount: lenses.length,
dateRange,
}}
debug={!IS_PRODUCTION}
/>
);
}

View File

@ -0,0 +1,349 @@
'use client';
import ScoreCard from '@/components/ScoreCard';
import ScoreCardRow from '@/components/ScoreCardRow';
import { dateRangeForPhotos } from '@/photo';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { FaCamera } from 'react-icons/fa';
import { FaTag } from 'react-icons/fa';
import { FaCircleInfo, FaRegCalendar } from 'react-icons/fa6';
import { HiOutlinePhotograph } from 'react-icons/hi';
import { MdAspectRatio } from 'react-icons/md';
import { PiWarningBold } from 'react-icons/pi';
import { TbCone, TbSparkles } from 'react-icons/tb';
import { getGitHubMeta } from '../../platforms/github';
import { BiGitBranch, BiGitCommit, BiLogoGithub } from 'react-icons/bi';
import {
TEMPLATE_REPO_BRANCH,
TEMPLATE_REPO_OWNER,
TEMPLATE_REPO_NAME,
VERCEL_GIT_COMMIT_SHA_SHORT,
VERCEL_GIT_COMMIT_MESSAGE,
TEMPLATE_REPO_URL_FORK,
TEMPLATE_REPO_URL_README,
} from '@/app-core/config';
import { AdminAppInsights, hasTemplateRecommendations, PhotoStats } from '.';
import EnvVar from '@/components/EnvVar';
import { IoSyncCircle } from 'react-icons/io5';
import clsx from 'clsx/lite';
import { PATH_ADMIN_OUTDATED } from '@/app-core/paths';
import { LiaBroomSolid } from 'react-icons/lia';
import { IoMdGrid } from 'react-icons/io';
import { RiSpeedMiniLine } from 'react-icons/ri';
import AdminLink from '../AdminLink';
import AdminEmptyState from '../AdminEmptyState';
const DEBUG_COMMIT_SHA = '4cd29ed';
const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes';
const DEBUG_BEHIND_BY = 9;
const DEBUG_PHOTOS_COUNT_OUTDATED = 7;
const readmeAnchor = (anchor: string) =>
<AdminLink href={`${TEMPLATE_REPO_URL_README}#${anchor}`}>
README/{anchor}
</AdminLink>;
const renderLabeledEnvVar = (label: string, envVar: string, value = '1') =>
<div className="flex flex-col gap-1.5">
<span className="text-xs uppercase font-medium tracking-wider">
{label}
</span>
<EnvVar variable={envVar} value={value} />
</div>;
export default function AdminAppInsightsClient({
codeMeta,
insights,
photoStats: {
photosCount,
photosCountHidden,
photosCountOutdated,
tagsCount,
camerasCount,
filmSimulationsCount,
lensesCount,
dateRange,
},
debug,
}: {
codeMeta?: Awaited<ReturnType<typeof getGitHubMeta>>
insights: AdminAppInsights
photoStats: PhotoStats
debug?: boolean
}) {
const {
noFork,
forkBehind,
noAi,
noAiRateLimiting,
outdatedPhotos,
photoMatting,
gridFirst,
noStaticOptimization,
} = insights;
const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange);
const branchLink = <a
className="truncate"
href={codeMeta?.urlBranch}
target="blank"
>
{codeMeta?.branch ?? TEMPLATE_REPO_BRANCH}
</a>;
return (
<div className="space-y-6 md:space-y-8">
{(codeMeta || debug) && <>
<ScoreCard title="Source code">
{(noFork || debug) &&
<ScoreCardRow
icon={<FaCircleInfo
size={15}
className="text-blue-500 translate-y-[1px]"
/>}
content="This template is not forked"
expandContent={<>
<AdminLink href={TEMPLATE_REPO_URL_FORK}>
Fork original template
</AdminLink>
{' '}
to receive the latest fixes and features.
{' '}
Additional instructions in
{' '}
{readmeAnchor('receiving-updates')}.
</>}
/>}
{(forkBehind || debug) && <ScoreCardRow
icon={<IoSyncCircle
size={18}
className="text-blue-500"
/>}
content={<>
This fork is
{' '}
<span className={clsx(
'text-blue-600 bg-blue-100/60',
'dark:text-blue-400 dark:bg-blue-900/50',
'px-1.5 pt-[1px] pb-0.5 rounded-md',
)}>
{codeMeta?.behindBy ?? DEBUG_BEHIND_BY}
{' '}
{(codeMeta?.behindBy ?? DEBUG_BEHIND_BY) === 1
? 'commit'
: 'commits'}
</span>
{' '}
behind
</>}
expandContent={<>
<AdminLink href={codeMeta?.urlRepo ?? ''}>
Sync your fork
</AdminLink>
{' '}
to receive the latest fixes and features.
</>}
/>}
<ScoreCardRow
icon={<BiLogoGithub size={17} />}
content={<div
className="flex flex-wrap gap-x-4 gap-y-1 overflow-auto"
>
<div className="flex items-center gap-1 *:whitespace-nowrap">
<a
href={codeMeta?.urlOwner}
target="blank"
>
{codeMeta?.owner ?? TEMPLATE_REPO_OWNER}
</a>
<div>/</div>
<a
href={codeMeta?.urlRepo}
target="blank"
>
{codeMeta?.repo ?? TEMPLATE_REPO_NAME}
</a>
</div>
<div className="hidden sm:flex items-center gap-1 min-w-0">
<BiGitBranch size={17} />
{branchLink}
</div>
</div>}
/>
<ScoreCardRow
className="sm:hidden"
icon={<BiGitBranch size={17} />}
content={branchLink}
/>
<ScoreCardRow
icon={<BiGitCommit
size={18}
className="translate-y-[-0.5px]"
/>}
content={<a
href={codeMeta?.urlCommit}
target="blank"
className="flex items-center gap-2"
>
<span className="text-medium hidden sm:inline-block">
{VERCEL_GIT_COMMIT_SHA_SHORT ?? DEBUG_COMMIT_SHA}
</span>
<span className="truncate">
{VERCEL_GIT_COMMIT_MESSAGE ?? DEBUG_COMMIT_MESSAGE}
</span>
</a>}
/>
</ScoreCard>
</>}
<ScoreCard title="Template recommendations">
{(hasTemplateRecommendations(insights) || debug)
? <>
{(noAiRateLimiting || debug) && <ScoreCardRow
icon={<PiWarningBold
size={17}
className="translate-x-[0.5px] text-amber-600"
/>}
content="AI enabled without rate limiting"
expandContent={<>
Create Vercel KV store and link to this project
in order prevent abuse by to enabling rate limiting.
</>}
/>}
{(noStaticOptimization || debug) && <ScoreCardRow
icon={<RiSpeedMiniLine
size={19}
className="translate-x-[1px] translate-y-[-1.5px]"
/>}
content="Speed up page load times"
expandContent={<>
Improve load times by enabling static optimization
{' '}
on:
<div className="flex flex-col gap-y-4 mt-3">
{renderLabeledEnvVar(
'Photo pages',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
)}
{renderLabeledEnvVar(
'Photo OG images',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES',
)}
{renderLabeledEnvVar(
'Category pages (tags, cameras, etc.)',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES',
)}
{renderLabeledEnvVar(
'Category OG images',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES',
)}
<span>
See {readmeAnchor('performance')} for cost implications.
</span>
</div>
</>}
/>}
{(noAi || debug) && <ScoreCardRow
icon={<TbSparkles size={17} />}
content="Improve SEO + accessibility with AI"
expandContent={<>
Enable automatic AI text generation
{' '}
by setting <EnvVar variable="OPENAI_SECRET_KEY" />.
{' '}
Further instruction and cost considerations in
{' '}
{readmeAnchor('ai-text-generation')}.
</>}
/>}
{(photoMatting || debug) && <ScoreCardRow
icon={<MdAspectRatio
size={17}
className="rotate-90 translate-x-[-1px]"
/>}
content="Vertical photos may benefit from matting"
expandContent={<>
Enable photo matting to make
{' '}
portrait and landscape photos appear more consistent
{' '}
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" value="1" />.
</>}
/>}
{(gridFirst || debug) && <ScoreCardRow
icon={<IoMdGrid size={18} className="translate-y-[-1px]" />}
content="Grid homepage"
expandContent={<>
Now that you have enough photos, consider switching your
{' '}
default view to grid by setting
{' '}
{/* eslint-disable-next-line max-len */}
<EnvVar variable="NEXT_PUBLIC_GRID_HOMEPAGE_ENABLED" value="1" />.
</>}
/>}
</>
: <AdminEmptyState includeContainer={false}>
Nothing to report!
</AdminEmptyState>}
</ScoreCard>
<ScoreCard title="Library Stats">
{(outdatedPhotos || debug) && <ScoreCardRow
icon={<LiaBroomSolid
size={19}
className="translate-y-[-2px] text-amber-600"
/>}
// eslint-disable-next-line max-len
content={`${photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED} outdated ${(photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED) === 1 ? 'photo' : 'photos'}`}
expandPath={PATH_ADMIN_OUTDATED}
/>}
<ScoreCardRow
icon={<HiOutlinePhotograph
size={17}
className="translate-y-[0.5px]"
/>}
content={<>
{photosCount} photos
{photosCountHidden > 0 &&
` (${photosCountHidden} hidden)`}
</>}
/>
<ScoreCardRow
icon={<FaTag
size={12}
className="translate-y-[3px]"
/>}
content={`${tagsCount} tags`}
/>
<ScoreCardRow
icon={<FaCamera
size={13}
className="translate-y-[2px]"
/>}
content={`${camerasCount} cameras`}
/>
{filmSimulationsCount &&
<ScoreCardRow
icon={<span className="inline-flex w-3">
<PhotoFilmSimulationIcon
className="shrink-0 translate-x-[-1px] translate-y-[-0.5px]"
height={18}
/>
</span>}
content={`${filmSimulationsCount} film simulations`}
/>}
<ScoreCardRow
icon={<TbCone className="rotate-[270deg] translate-x-[-2px]" />}
content={`${lensesCount} lenses`}
/>
<ScoreCardRow
icon={<FaRegCalendar
size={13}
className="translate-y-[1.5px] translate-x-[-2px]"
/>}
content={descriptionWithSpaces}
/>
</ScoreCard>
</div>
);
}

View File

@ -0,0 +1,27 @@
import clsx from 'clsx/lite';
import { LuLightbulb } from 'react-icons/lu';
export default function AdminAppInsightsIcon({
indicator,
}: {
indicator?: 'blue' | 'yellow'
}) {
return (
<span className="inline-flex relative">
<LuLightbulb
size={19}
className="translate-y-[3px]"
/>
{indicator && <span className={clsx(
'absolute',
'top-[2px] right-[0.5px]',
'size-2 rounded-full',
indicator === 'yellow'
? 'bg-amber-500'
: indicator === 'blue'
? 'bg-blue-500'
: undefined,
)} />}
</span>
);
}

View File

@ -0,0 +1,44 @@
import { PhotoDateRange } from '@/photo';
export type AdminAppInsight =
'noFork' |
'forkBehind' |
'noAi' |
'noAiRateLimiting' |
'outdatedPhotos' |
'photoMatting' |
'gridFirst' |
'noStaticOptimization';
const RECOMMENDATIONS: AdminAppInsight[] = [
'noAi',
'noAiRateLimiting',
'photoMatting',
'gridFirst',
'noStaticOptimization',
];
export type AdminAppInsights = Record<AdminAppInsight, boolean>
export const hasTemplateRecommendations = (insights: AdminAppInsights) =>
RECOMMENDATIONS.some(insight => insights[insight]);
export interface PhotoStats {
photosCount: number
photosCountHidden: number
photosCountOutdated: number
tagsCount: number
camerasCount: number
filmSimulationsCount: number
lensesCount: number
dateRange?: PhotoDateRange
}
export const getInsightIndicator = ({
forkBehind,
noAiRateLimiting,
outdatedPhotos,
}: AdminAppInsights) =>
forkBehind ||
noAiRateLimiting ||
outdatedPhotos;

View File

@ -18,7 +18,7 @@ import { formatCount, formatCountDescriptive } from '@/utility/string';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { IoMdCamera } from 'react-icons/io';
import { ADMIN_DEBUG_TOOLS_ENABLED, SHOW_FILM_SIMULATIONS } from './config';
import { labelForFilmSimulation } from '@/vendors/fujifilm';
import { labelForFilmSimulation } from '@/platforms/fujifilm';
import { getUniqueFocalLengths } from '@/photo/db/query';
import { formatFocalLength } from '@/focal';
import { TbCone } from 'react-icons/tb';

View File

@ -2,9 +2,9 @@
import { clsx } from 'clsx/lite';
import SiteGrid from '../components/SiteGrid';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import ThemeSwitcher from '@/app-core/ThemeSwitcher';
import Link from 'next/link';
import { SHOW_REPO_LINK } from '@/site/config';
import { SHOW_REPO_LINK } from '@/app-core/config';
import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';

View File

@ -4,7 +4,7 @@ import { clsx } from 'clsx/lite';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import SiteGrid from '../components/SiteGrid';
import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher';
import ViewSwitcher, { SwitcherSelection } from '@/app-core/ViewSwitcher';
import {
PATH_ROOT,
isPathAdmin,
@ -12,7 +12,7 @@ import {
isPathGrid,
isPathProtected,
isPathSignIn,
} from '@/site/paths';
} from '@/app-core/paths';
import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
import {

View File

@ -30,9 +30,7 @@ export default function SecretGenerator() {
'flex flex-nowrap items-center gap-2 leading-none -mx-1',
)}>
{secret ? <span>{secret}</span> : <Spinner />}
<div
className="flex items-center gap-0.5 translate-y-[-2px]"
>
<div className="flex items-center gap-0.5 translate-y-[-0.5px]">
<CopyButton label="Secret" text={secret} />
</div>
</div>

View File

@ -1,12 +1,12 @@
import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem';
import IconFeed from '@/site/IconFeed';
import IconGrid from '@/site/IconGrid';
import IconFeed from '@/app-core/IconFeed';
import IconGrid from '@/app-core/IconGrid';
import {
PATH_ADMIN_PHOTOS,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
} from '@/site/paths';
} from '@/app-core/paths';
import { BiLockAlt } from 'react-icons/bi';
import IconSearch from './IconSearch';
import { useAppState } from '@/state/AppState';

View File

@ -6,17 +6,24 @@ import type { StorageType } from '@/services/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
// HARD-CODED GLOBAL CONFIGURATION
export const SHOULD_PREFETCH_ALL_LINKS: boolean | undefined = undefined;
// META / SOURCE / DOMAINS
export const SITE_TITLE =
process.env.NEXT_PUBLIC_SITE_TITLE ||
'Photo Blog';
// TEMPLATE META
// SOURCE
export const TEMPLATE_BASE_OWNER = 'sambecker';
export const TEMPLATE_BASE_REPO = 'exif-photo-blog';
export const TEMPLATE_BASE_BRANCH = 'main';
export const TEMPLATE_TITLE = 'Photo Blog';
export const TEMPLATE_DESCRIPTION = 'Store photos with original camera data';
// SOURCE CODE
export const TEMPLATE_REPO_OWNER = 'sambecker';
export const TEMPLATE_REPO_NAME = 'exif-photo-blog';
export const TEMPLATE_REPO_BRANCH = 'main';
// eslint-disable-next-line max-len
export const TEMPLATE_REPO_URL = `https://github.com/${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`;
export const TEMPLATE_REPO_URL_FORK = `${TEMPLATE_REPO_URL}/fork`;
// eslint-disable-next-line max-len
export const TEMPLATE_REPO_URL_README = `${TEMPLATE_REPO_URL}?tab=readme-ov-file`;
export const VERCEL_GIT_PROVIDER =
process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER;
@ -59,6 +66,12 @@ export const IS_PREVIEW = VERCEL_ENV === 'preview';
export const VERCEL_BYPASS_KEY = 'x-vercel-protection-bypass';
export const VERCEL_BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
// SITE META
export const SITE_TITLE =
process.env.NEXT_PUBLIC_SITE_TITLE ||
TEMPLATE_TITLE;
// User-facing domain, potential site title
const SITE_DOMAIN =
process.env.NEXT_PUBLIC_SITE_DOMAIN ||
@ -90,11 +103,14 @@ export const SITE_ABOUT = process.env.NEXT_PUBLIC_SITE_ABOUT;
export const HAS_DEFINED_SITE_DESCRIPTION =
Boolean(process.env.NEXT_PUBLIC_SITE_DESCRIPTION);
// STORAGE
// STORAGE: DATABASE
export const HAS_DATABASE =
Boolean(process.env.POSTGRES_URL);
export const POSTGRES_SSL_ENABLED =
process.env.DISABLE_POSTGRES_SSL === '1' ? false : true;
// STORAGE: VERCEL KV
export const HAS_VERCEL_KV =
Boolean(process.env.KV_URL);
@ -162,6 +178,11 @@ export const STATICALLY_OPTIMIZED_PHOTO_CATEGORIES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES === '1';
export const STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES === '1';
export const HAS_STATIC_OPTIMIZATION =
STATICALLY_OPTIMIZED_PHOTOS ||
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES ||
STATICALLY_OPTIMIZED_PHOTO_CATEGORIES ||
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES;
export const PRESERVE_ORIGINAL_UPLOADS =
process.env.NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS === '1' ||
// Legacy environment variable name
@ -232,7 +253,7 @@ export const ADMIN_DEBUG_TOOLS_ENABLED = process.env.ADMIN_DEBUG_TOOLS === '1';
export const ADMIN_DB_OPTIMIZE_ENABLED = process.env.ADMIN_DB_OPTIMIZE === '1';
export const ADMIN_SQL_DEBUG_ENABLED = process.env.ADMIN_SQL_DEBUG === '1';
export const CONFIG_CHECKLIST_STATUS = {
export const APP_CONFIGURATION = {
// Storage
hasDatabase: HAS_DATABASE,
isPostgresSslEnabled: POSTGRES_SSL_ENABLED,
@ -272,11 +293,7 @@ export const CONFIG_CHECKLIST_STATUS = {
hasAiTextAutoGeneratedFields:
Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS),
// Performance
isStaticallyOptimized: (
STATICALLY_OPTIMIZED_PHOTOS ||
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES ||
STATICALLY_OPTIMIZED_PHOTO_CATEGORIES
),
isStaticallyOptimized: HAS_STATIC_OPTIMIZATION,
arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS,
arePhotoOGImagesStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES,
arePhotoCategoriesStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTO_CATEGORIES,
@ -301,7 +318,7 @@ export const CONFIG_CHECKLIST_STATUS = {
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
gridAspectRatio: GRID_ASPECT_RATIO,
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
gridDensity: HIGH_DENSITY_GRID,
hasHighGridDensity: HIGH_DENSITY_GRID,
hasGridDensityPreference:
Boolean(process.env.NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS),
// Settings
@ -326,11 +343,11 @@ export const CONFIG_CHECKLIST_STATUS = {
commitUrl: VERCEL_GIT_COMMIT_URL,
};
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
export type ConfigChecklistStatus = typeof APP_CONFIGURATION;
export const IS_SITE_READY =
CONFIG_CHECKLIST_STATUS.hasDatabase &&
CONFIG_CHECKLIST_STATUS.hasStorageProvider &&
CONFIG_CHECKLIST_STATUS.hasAuthSecret &&
CONFIG_CHECKLIST_STATUS.hasAdminUser;
APP_CONFIGURATION.hasDatabase &&
APP_CONFIGURATION.hasStorageProvider &&
APP_CONFIGURATION.hasAuthSecret &&
APP_CONFIGURATION.hasAdminUser;

20
src/app-core/font.ts Normal file
View File

@ -0,0 +1,20 @@
import fs from 'fs';
import path from 'path';
import { cwd } from 'process';
const FONT_IBM_PLEX_MONO_FAMILY = 'IBMPlexMono';
const FONT_IBM_PLEX_MONO_PATH = '/public/fonts/IBMPlexMono-Medium.ttf';
const getFontData = async () =>
fs.readFileSync(path.join(cwd(), FONT_IBM_PLEX_MONO_PATH));
export const getIBMPlexMonoMedium = () => getFontData()
.then(data => ({
fontFamily: FONT_IBM_PLEX_MONO_FAMILY,
fonts: [{
name: FONT_IBM_PLEX_MONO_FAMILY,
data,
weight: 500,
style: 'normal',
} as const],
}));

View File

@ -39,6 +39,7 @@ export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`;
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
// Debug paths
@ -213,6 +214,9 @@ export const isPathTopLevelAdmin = (pathname?: string) =>
export const isPathAdminConfiguration = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION);
export const isPathAdminInsights = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_INSIGHTS);
export const isPathProtected = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN) ||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)) ||

View File

@ -1,27 +1,14 @@
import ClearCacheButton from '@/admin/ClearCacheButton';
import GitHubForkStatusBadge from '@/admin/github/GitHubForkStatusBadge';
import Container from '@/components/Container';
import SiteGrid from '@/components/SiteGrid';
import { IS_DEVELOPMENT, IS_VERCEL_GIT_PROVIDER_GITHUB } from '@/site/config';
import SiteChecklist from '@/site/SiteChecklist';
import AdminAppConfiguration from '@/admin/AdminAppConfiguration';
import AdminInfoPage from '@/admin/AdminInfoPage';
export default async function AdminConfigurationPage() {
export default function AdminAppConfigurationPage() {
return (
<SiteGrid
contentMain={
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="grow">
App Configuration
</div>
{(IS_VERCEL_GIT_PROVIDER_GITHUB || IS_DEVELOPMENT) &&
<GitHubForkStatusBadge />}
<ClearCacheButton />
</div>
<Container spaceChildren={false}>
<SiteChecklist />
</Container>
</div>}
/>
<AdminInfoPage
title="App Configuration"
accessory={<ClearCacheButton />}
>
<AdminAppConfiguration />
</AdminInfoPage>
);
}

View File

@ -0,0 +1,8 @@
import AdminAppInsights from '@/admin/insights/AdminAppInsights';
import AdminInfoPage from '@/admin/AdminInfoPage';
export default async function AdminInsightsPage() {
return <AdminInfoPage title="App Insights">
<AdminAppInsights />
</AdminInfoPage>;
}

View File

@ -1,7 +1,7 @@
import { getPhotos } from '@/photo/db/query';
import { OUTDATED_THRESHOLD } from '@/photo';
import AdminOutdatedClient from '@/admin/AdminOutdatedClient';
import { AI_TEXT_GENERATION_ENABLED } from '@/site/config';
import { AI_TEXT_GENERATION_ENABLED } from '@/app-core/config';
export const maxDuration = 60;

View File

@ -1,12 +1,12 @@
import { redirect } from 'next/navigation';
import { getPhotoNoStore, getUniqueTagsCached } from '@/photo/cache';
import { PATH_ADMIN } from '@/site/paths';
import { PATH_ADMIN } from '@/app-core/paths';
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
import {
AI_TEXT_GENERATION_ENABLED,
BLUR_ENABLED,
IS_PREVIEW,
} from '@/site/config';
} from '@/app-core/config';
import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server';
import { getNextImageUrlForManipulation } from '@/services/next-image';

View File

@ -2,7 +2,7 @@ import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/photo/cache';
import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/site/paths';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/app-core/paths';
import PhotoLightbox from '@/photo/PhotoLightbox';
import { getPhotosMeta } from '@/photo/db/query';
import AdminTagBadge from '@/admin/AdminTagBadge';

View File

@ -1,4 +1,4 @@
import { PATH_ADMIN } from '@/site/paths';
import { PATH_ADMIN } from '@/app-core/paths';
import { extractImageDataFromBlobPath } from '@/photo/server';
import { redirect } from 'next/navigation';
import { getUniqueTagsCached } from '@/photo/cache';
@ -7,7 +7,7 @@ import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
BLUR_ENABLED,
} from '@/site/config';
} from '@/app-core/config';
import ErrorNote from '@/components/ErrorNote';
export const maxDuration = 60;

View File

@ -1,10 +1,10 @@
import { getPhotosCached } from '@/photo/cache';
import { API_PHOTO_REQUEST_LIMIT, formatPhotoForApi } from '@/site/api';
import { API_PHOTO_REQUEST_LIMIT, formatPhotoForApi } from '@/app-core/api';
import {
BASE_URL,
PUBLIC_API_ENABLED,
SITE_TITLE,
} from '@/site/config';
} from '@/app-core/config';
export const dynamic = 'force-dynamic';

View File

@ -7,7 +7,7 @@ import {
cloudflareR2Client,
cloudflareR2PutObjectCommandForKey,
} from '@/services/storage/cloudflare-r2';
import { CURRENT_STORAGE } from '@/site/config';
import { CURRENT_STORAGE } from '@/app-core/config';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export async function GET(

View File

@ -2,7 +2,7 @@
import SiteGrid from '@/components/SiteGrid';
import { clsx } from 'clsx/lite';
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/vendors/fujifilm';
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm';
import PhotoFilmSimulation from
'@/simulation/PhotoFilmSimulation';
import { useEffect, useState } from 'react';

View File

@ -1,4 +1,4 @@
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/vendors/fujifilm';
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm';
import PhotoFilmSimulation from
'@/simulation/PhotoFilmSimulation';

View File

@ -9,7 +9,7 @@ import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
} from '@/app-core/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { FilmSimulation } from '@/simulation';
import {

View File

@ -6,7 +6,7 @@ import {
import FilmSimulationImageResponse from
'@/image-response/FilmSimulationImageResponse';
import { FilmSimulation } from '@/simulation';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
@ -14,7 +14,7 @@ import { getUniqueFilmSimulations } from '@/photo/db/query';
import {
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
IS_PRODUCTION,
} from '@/site/config';
} from '@/app-core/config';
export let generateStaticParams:
(() => Promise<{ simulation: FilmSimulation }[]>) | undefined = undefined;

View File

@ -2,9 +2,9 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueFilmSimulations } from '@/photo/db/query';
import { FilmSimulation, generateMetaForFilmSimulation } from '@/simulation';
import FilmSimulationOverview from '@/simulation/FilmSimulationOverview';
import { IS_PRODUCTION } from '@/site/config';
import { IS_PRODUCTION } from '@/app-core/config';
import { getPhotosFilmSimulationDataCached } from '@/simulation/data';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app-core/config';
import { Metadata } from 'next/types';
import { cache } from 'react';

View File

@ -9,7 +9,7 @@ import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
} from '@/app-core/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCached } from '@/photo/cache';
import { cache } from 'react';

View File

@ -3,7 +3,7 @@ import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
} from '@/image-response';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import FocalLengthImageResponse from
@ -14,7 +14,7 @@ import { getUniqueFocalLengths } from '@/photo/db/query';
import {
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
IS_PRODUCTION,
} from '@/site/config';
} from '@/app-core/config';
export let generateStaticParams:
(() => Promise<{ focal: string }[]>) | undefined = undefined;

View File

@ -2,10 +2,10 @@ import { generateMetaForFocalLength, getFocalLengthFromString } from '@/focal';
import FocalLengthOverview from '@/focal/FocalLengthOverview';
import { getPhotosFocalLengthDataCached } from '@/focal/data';
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { IS_PRODUCTION } from '@/site/config';
import { IS_PRODUCTION } from '@/app-core/config';
import { getUniqueFocalLengths } from '@/photo/db/query';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { PATH_ROOT } from '@/site/paths';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app-core/config';
import { PATH_ROOT } from '@/app-core/paths';
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { cache } from 'react';

View File

@ -4,7 +4,7 @@ import {
MAX_PHOTOS_TO_SHOW_OG,
} from '@/image-response';
import HomeImageResponse from '@/image-response/HomeImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { isNextImageReadyBasedOnPhotos } from '@/photo';

View File

@ -7,15 +7,15 @@ import {
SITE_DESCRIPTION,
SITE_DOMAIN_OR_TITLE,
SITE_TITLE,
} from '@/site/config';
} from '@/app-core/config';
import AppStateProvider from '@/state/AppStateProvider';
import ToasterWithThemes from '@/toast/ToasterWithThemes';
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
import { Metadata } from 'next/types';
import { ThemeProvider } from 'next-themes';
import Nav from '@/site/Nav';
import Footer from '@/site/Footer';
import CommandK from '@/site/CommandK';
import Nav from '@/app-core/Nav';
import Footer from '@/app-core/Footer';
import CommandK from '@/app-core/CommandK';
import SwrConfigClient from '../state/SwrConfigClient';
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
import ShareModals from '@/share/ShareModals';
@ -72,11 +72,8 @@ export default function RootLayout({
>
<body>
<AppStateProvider>
<SwrConfigClient>
<ThemeProvider
attribute="class"
defaultTheme={DEFAULT_THEME}
>
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
<SwrConfigClient>
<main className={clsx(
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
@ -96,12 +93,12 @@ export default function RootLayout({
<Footer />
</main>
<CommandK />
</ThemeProvider>
</SwrConfigClient>
<Analytics debug={false} />
<SpeedInsights debug={false} />
<PhotoEscapeHandler />
<ToasterWithThemes />
</SwrConfigClient>
<Analytics debug={false} />
<SpeedInsights debug={false} />
<PhotoEscapeHandler />
<ToasterWithThemes />
</ThemeProvider>
</AppStateProvider>
</body>
</html>

View File

@ -1,13 +1,13 @@
import { getPhotoCached } from '@/photo/cache';
import { IMAGE_OG_DIMENSION } from '@/image-response';
import PhotoImageResponse from '@/image-response/PhotoImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import {
IS_PRODUCTION,
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES,
} from '@/site/config';
} from '@/app-core/config';
import { getPhotoIds } from '@/photo/db/query';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { isNextImageReadyBasedOnPhotos } from '@/photo';

View File

@ -9,10 +9,10 @@ import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
} from '@/app-core/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCached } from '@/photo/cache';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_PHOTOS } from '@/site/config';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_PHOTOS } from '@/app-core/config';
import { getPhotoIds } from '@/photo/db/query';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { cache } from 'react';

View File

@ -7,7 +7,7 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { cache } from 'react';
import { getPhotos, getPhotosMeta } from '@/photo/db/query';
import { GRID_HOMEPAGE_ENABLED } from '@/site/config';
import { GRID_HOMEPAGE_ENABLED } from '@/app-core/config';
import { getPhotoSidebarData } from '@/photo/data';
import PhotoGridPage from '@/photo/PhotoGridPage';
import PhotoFeedPage from '@/photo/PhotoFeedPage';

View File

@ -9,7 +9,7 @@ import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
} from '@/app-core/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import {
getPhotosMetaCached,

View File

@ -5,7 +5,7 @@ import {
MAX_PHOTOS_TO_SHOW_PER_TAG,
} from '@/image-response';
import CameraImageResponse from '@/image-response/CameraImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
@ -13,7 +13,7 @@ import { getUniqueCameras } from '@/photo/db/query';
import {
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
IS_PRODUCTION,
} from '@/site/config';
} from '@/app-core/config';
export let generateStaticParams:
(() => Promise<{ camera: Camera }[]>) | undefined = undefined;

View File

@ -5,8 +5,8 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getPhotosCameraDataCached } from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview';
import { cache } from 'react';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { IS_PRODUCTION } from '@/site/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app-core/config';
import { IS_PRODUCTION } from '@/app-core/config';
import { getUniqueCameras } from '@/photo/db/query';
const getPhotosCameraDataCachedCached = cache((

View File

@ -1,6 +1,6 @@
import { auth } from '@/auth';
import SignInForm from '@/auth/SignInForm';
import { PATH_ADMIN } from '@/site/paths';
import { PATH_ADMIN } from '@/app-core/paths';
import { clsx } from 'clsx/lite';
import { redirect } from 'next/navigation';

View File

@ -9,7 +9,7 @@ import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
} from '@/app-core/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCached } from '@/photo/cache';
import { cache } from 'react';

View File

@ -4,7 +4,7 @@ import {
MAX_PHOTOS_TO_SHOW_PER_TAG,
} from '@/image-response';
import TagImageResponse from '@/image-response/TagImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
@ -12,7 +12,7 @@ import { getUniqueTags } from '@/photo/db/query';
import {
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
IS_PRODUCTION,
} from '@/site/config';
} from '@/app-core/config';
export let generateStaticParams:
(() => Promise<{ tag: string }[]>) | undefined = undefined;

View File

@ -1,8 +1,8 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueTags } from '@/photo/db/query';
import { IS_PRODUCTION } from '@/site/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/site/config';
import { PATH_ROOT } from '@/site/paths';
import { IS_PRODUCTION } from '@/app-core/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app-core/config';
import { PATH_ROOT } from '@/app-core/paths';
import { generateMetaForTag } from '@/tag';
import TagOverview from '@/tag/TagOverview';
import { getPhotosTagDataCached } from '@/tag/data';

View File

@ -8,7 +8,7 @@ import {
getPhotosNearIdCached,
} from '@/photo/cache';
import { getPhotosMeta } from '@/photo/db/query';
import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths';
import { PATH_ROOT, absolutePathForPhoto } from '@/app-core/paths';
import { TAG_HIDDEN } from '@/tag';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';

View File

@ -4,7 +4,7 @@ import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotosNoStore } from '@/photo/cache';
import { getPhotosMeta } from '@/photo/db/query';
import { absolutePathForTag } from '@/site/paths';
import { absolutePathForTag } from '@/app-core/paths';
import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader';
import { Metadata } from 'next';

View File

@ -5,7 +5,7 @@ import {
} from '@/image-response';
import TemplateImageResponse from
'@/image-response/TemplateImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { isNextImageReadyBasedOnPhotos } from '@/photo';

View File

@ -5,7 +5,7 @@ import {
} from '@/image-response';
import TemplateImageResponse from
'@/image-response/TemplateImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { getIBMPlexMonoMedium } from '@/app-core/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { isNextImageReadyBasedOnPhotos } from '@/photo';

View File

@ -1,24 +1,25 @@
/* eslint-disable max-len */
import {
TEMPLATE_REPO_OWNER,
TEMPLATE_REPO_NAME,
TEMPLATE_DESCRIPTION,
TEMPLATE_TITLE,
} from '@/app-core/config';
import { NextResponse } from 'next/server';
const REQUIRE_ENV_VARS = false;
const TITLE = 'Photo Blog';
const DESCRIPTION = 'Store photos with original camera data';
const REPO_TEAM = 'sambecker';
const REPO_NAME = 'exif-photo-blog';
export function GET() {
const url = new URL('https://vercel.com/new/clone');
url.searchParams.set('demo-title', TITLE);
url.searchParams.set('demo-description', DESCRIPTION);
url.searchParams.set('demo-title', TEMPLATE_TITLE);
url.searchParams.set('demo-description', TEMPLATE_DESCRIPTION);
url.searchParams.set('demo-url', 'https://photos.sambecker.com');
url.searchParams.set('demo-description', DESCRIPTION);
url.searchParams.set('demo-description', TEMPLATE_DESCRIPTION);
url.searchParams.set('demo-image', 'https://photos.sambecker.com/template-image-tight');
url.searchParams.set('project-name', TITLE);
url.searchParams.set('repository-name', REPO_NAME);
url.searchParams.set('repository-url', `https://github.com/${REPO_TEAM}/${REPO_NAME}`);
url.searchParams.set('project-name', TEMPLATE_TITLE);
url.searchParams.set('repository-name', TEMPLATE_REPO_NAME);
url.searchParams.set('repository-url', `https://github.com/${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`);
url.searchParams.set('from', 'templates');
url.searchParams.set('skippable-integrations', '1');
if (REQUIRE_ENV_VARS) {

View File

@ -10,7 +10,7 @@ import {
signIn,
signOut,
} from '@/auth';
import { PATH_ADMIN_PHOTOS, PATH_ROOT } from '@/site/paths';
import { PATH_ADMIN_PHOTOS, PATH_ROOT } from '@/app-core/paths';
import type { Session } from 'next-auth';
import { redirect } from 'next/navigation';

View File

@ -1,4 +1,4 @@
import { isPathProtected } from '@/site/paths';
import { isPathProtected } from '@/app-core/paths';
import NextAuth, { User } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';

View File

@ -1,5 +1,5 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForCameraImage, pathForCamera } from '@/site/paths';
import { absolutePathForCameraImage, pathForCamera } from '@/app-core/paths';
import OGTile from '@/components/OGTile';
import { Camera } from '.';
import { descriptionForCameraPhotos, titleForCamera } from './meta';

View File

@ -1,4 +1,4 @@
import { absolutePathForCamera } from '@/site/paths';
import { absolutePathForCamera } from '@/app-core/paths';
import { PhotoSetAttributes } from '../photo';
import ShareModal from '@/share/ShareModal';
import CameraOGTile from './CameraOGTile';

View File

@ -1,5 +1,5 @@
import { AiFillApple } from 'react-icons/ai';
import { pathForCamera } from '@/site/paths';
import { pathForCamera } from '@/app-core/paths';
import { IoMdCamera } from 'react-icons/io';
import { Camera, formatCameraText, isCameraApple } from '.';
import EntityLink, {

View File

@ -8,7 +8,7 @@ import { Camera, cameraFromPhoto, formatCameraText } from '.';
import {
absolutePathForCamera,
absolutePathForCameraImage,
} from '@/site/paths';
} from '@/app-core/paths';
// Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts

View File

@ -5,7 +5,7 @@ import Badge from './Badge';
import ResponsiveText from './primitives/ResponsiveText';
import { parameterize } from '@/utility/string';
export default function Checklist({
export default function ChecklistGroup({
title,
titleShort,
icon,

View File

@ -42,7 +42,7 @@ export default function ChecklistRow({
{experimental &&
<ExperimentalBadge className="translate-y-[-0.5px]" />}
</div>
<div>
<div className="leading-relaxed">
{children}
</div>
</div>

View File

@ -25,13 +25,13 @@ export default function Container({
switch (color) {
case 'gray': return [
'text-medium',
'bg-gray-50 border-gray-200',
'dark:bg-gray-900/40 dark:border-gray-800',
'bg-gray-50 dark:bg-gray-900/40',
'border-gray-200 dark:border-gray-800',
];
case 'blue': return [
'text-main',
'bg-blue-50/50 border-blue-200',
'dark:bg-blue-950/30 dark:border-blue-600/50',
'text-blue-900 dark:text-blue-300',
'bg-blue-50/50 dark:bg-blue-950/30',
'border-blue-200 dark:border-blue-600/30',
];
case 'red': return [
'text-red-600 dark:text-red-500/90',
@ -41,7 +41,7 @@ export default function Container({
case 'yellow': return [
'text-amber-700 dark:text-amber-500/90',
'bg-amber-50/50 dark:bg-amber-950/30',
'border-amber-200/80 dark:border-amber-800/30',
'border-amber-600/30 dark:border-amber-800/30',
];
}
};

View File

@ -7,17 +7,19 @@ export default function CopyButton({
label,
text,
subtle,
className,
}: {
label: string
text?: string,
subtle?: boolean
className?: string
}) {
return (
<LoaderButton
icon={<BiCopy size={15} />}
className={clsx(
'translate-y-[2px]',
subtle && 'text-gray-300 dark:text-gray-700',
className,
)}
onClick={text
? () => {

40
src/components/EnvVar.tsx Normal file
View File

@ -0,0 +1,40 @@
import clsx from 'clsx/lite';
import CopyButton from './CopyButton';
export default function EnvVar({
variable,
value,
includeCopyButton = true,
}: {
variable: string,
value?: string,
includeCopyButton?: boolean,
}) {
return (
<div
className={clsx(
'inline-flex',
'overflow-x-auto overflow-y-hidden',
)}
>
<span className="inline-flex items-center gap-1">
<span className={clsx(
'px-1.5 rounded-md',
'text-[11px] font-medium tracking-wider',
'text-gray-600 dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
'whitespace-nowrap',
)}>
{variable}{value && ` = ${value}`}
</span>
{includeCopyButton &&
<CopyButton
className="translate-y-[0.5px]"
label={variable}
text={variable}
subtle
/>}
</span>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import SiteGrid from './SiteGrid';
import { clsx } from 'clsx/lite';
import { PATH_ROOT } from '@/site/paths';
import { PATH_ROOT } from '@/app-core/paths';
import Link from 'next/link';
export default function HttpStatusPage({

View File

@ -6,9 +6,11 @@ import Link from 'next/link';
export default function LinkWithLoader({
loader,
children,
debugLoading,
...props
}: ComponentProps<typeof Link> & {
loader: ReactNode
debugLoading?: boolean
}) {
return (
<LinkWithStatus {...props}>
@ -19,7 +21,7 @@ export default function LinkWithLoader({
)}>
{children}
</span>
{isLoading && <span className={clsx(
{(isLoading || debugLoading) && <span className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>

View File

@ -89,7 +89,7 @@ export default function LinkWithStatus({
{...props }
href={href}
className={clsx(
'relative flex transition-[colors,opacity]',
'relative inline-flex transition-[colors,opacity]',
(loadingClassName || isControlled)
? 'opacity-100'
: isLoading ? 'opacity-50' : 'opacity-100',

View File

@ -6,9 +6,9 @@ import { clsx } from 'clsx/lite';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
import { useRouter } from 'next/navigation';
import AnimateItems from './AnimateItems';
import { PATH_ROOT } from '@/site/paths';
import { PATH_ROOT } from '@/app-core/paths';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
import useMetaThemeColor from '@/site/useMetaThemeColor';
import useMetaThemeColor from '@/utility/useMetaThemeColor';
import useEscapeHandler from '@/utility/useEscapeHandler';
export default function Modal({

View File

@ -1,3 +1,4 @@
import { TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app-core/config';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { BiLogoGithub } from 'react-icons/bi';
@ -9,7 +10,7 @@ export default function RepoLink() {
Made with
</span>
<Link
href="http://github.com/sambecker/exif-photo-blog"
href={TEMPLATE_REPO_URL}
target="_blank"
className={clsx(
'flex items-center gap-0.5',
@ -21,7 +22,7 @@ export default function RepoLink() {
size={16}
className="translate-y-[0.5px] hidden xs:inline-block"
/>
exif-photo-blog
{TEMPLATE_REPO_NAME}
</Link>
</span>
);

View File

@ -0,0 +1,31 @@
import clsx from 'clsx/lite';
import { ReactNode } from 'react';
export default function ScoreCard({
title,
children,
className,
}: {
title?: string,
children: ReactNode,
className?: string,
}) {
return (
<div className="space-y-3">
{title &&
<div className={clsx(
'pl-[15px]',
'uppercase font-medium tracking-wider text-[0.8rem]',
'text-medium',
)}>
{title}
</div>}
<div className={clsx(
'component-surface shadow-xs divide-y divide-medium',
className,
)}>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
import { clsx } from 'clsx/lite';
import { ReactNode, useState } from 'react';
import {
LuChevronRight,
LuChevronsDownUp,
LuChevronsUpDown,
} from 'react-icons/lu';
import LinkWithStatus from './LinkWithStatus';
import Spinner from './Spinner';
const expandAccessoryClasses = clsx(
'flex items-center justify-center',
'w-9 h-8',
'*:shrink-0',
);
export default function ScoreCardRow({
icon,
content,
expandContent,
expandPath,
className,
}: {
icon: ReactNode
content: ReactNode
expandContent?: ReactNode
expandPath?: string
className?: string
}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={clsx(
'flex',
'py-2 pr-2',
className,
)}>
<div className={clsx(
'flex justify-center pt-[8px] w-11 sm:w-14',
'shrink-0 text-icon',
)}>
{icon}
</div>
<div className="grow space-y-2 py-1.5 w-full overflow-auto">
<div className={clsx(
'text-main pr-2',
// Truncate on small screens unless expanded
!isExpanded && 'truncate md:truncate-none',
)}>
{content}
</div>
{isExpanded &&
<div className="text-medium leading-relaxed">
{expandContent}
</div>}
</div>
{expandContent && <button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={expandAccessoryClasses}
>
{isExpanded
? <LuChevronsDownUp size={16} />
: <LuChevronsUpDown size={16} />}
</button>}
{expandPath && <LinkWithStatus
className={clsx('button', expandAccessoryClasses)}
href={expandPath}
>
{({ isLoading }) => <> {isLoading
? <Spinner />
: <LuChevronRight size={16} />}
</>}
</LinkWithStatus>}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More