Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-03-19 12:46:09 -05:00
commit 7fa7dce66e
17 changed files with 352 additions and 297 deletions

View File

@ -39,8 +39,8 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.6", "nanoid": "^5.0.6",
"next": "14.2.0-canary.23", "next": "14.2.0-canary.31",
"next-auth": "5.0.0-beta.13", "next-auth": "5.0.0-beta.15",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"postcss": "8.4.35", "postcss": "8.4.35",
"react": "18.2.0", "react": "18.2.0",

96
pnpm-lock.yaml generated
View File

@ -49,7 +49,7 @@ dependencies:
version: 7.2.0(eslint@8.57.0)(typescript@5.4.2) version: 7.2.0(eslint@8.57.0)(typescript@5.4.2)
'@vercel/analytics': '@vercel/analytics':
specifier: ^1.2.2 specifier: ^1.2.2
version: 1.2.2(next@14.2.0-canary.23)(react@18.2.0) version: 1.2.2(next@14.2.0-canary.31)(react@18.2.0)
'@vercel/blob': '@vercel/blob':
specifier: ^0.22.1 specifier: ^0.22.1
version: 0.22.1 version: 0.22.1
@ -58,7 +58,7 @@ dependencies:
version: 0.7.2 version: 0.7.2
'@vercel/speed-insights': '@vercel/speed-insights':
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10(next@14.2.0-canary.23)(react@18.2.0) version: 1.0.10(next@14.2.0-canary.31)(react@18.2.0)
autoprefixer: autoprefixer:
specifier: 10.4.18 specifier: 10.4.18
version: 10.4.18(postcss@8.4.35) version: 10.4.18(postcss@8.4.35)
@ -96,11 +96,11 @@ dependencies:
specifier: ^5.0.6 specifier: ^5.0.6
version: 5.0.6 version: 5.0.6
next: next:
specifier: 14.2.0-canary.23 specifier: 14.2.0-canary.31
version: 14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) version: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next-auth: next-auth:
specifier: 5.0.0-beta.13 specifier: 5.0.0-beta.15
version: 5.0.0-beta.13(next@14.2.0-canary.23)(react@18.2.0) version: 5.0.0-beta.15(next@14.2.0-canary.31)(react@18.2.0)
next-themes: next-themes:
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.0(react-dom@18.2.0)(react@18.2.0) version: 0.3.0(react-dom@18.2.0)(react@18.2.0)
@ -156,8 +156,8 @@ packages:
'@jridgewell/trace-mapping': 0.3.22 '@jridgewell/trace-mapping': 0.3.22
dev: false dev: false
/@auth/core@0.27.0: /@auth/core@0.28.0:
resolution: {integrity: sha512-3bydnRJIM/Al6mkYmb53MsC+6G8ojw3lLPzwgVnX4dCo6N2lrib6Wq6r0vxZIhuHGjLObqqtUfpeaEj5aeTHFg==} resolution: {integrity: sha512-/fh/tb/L4NMSYcyPoo4Imn8vN6MskcVfgESF8/ndgtI4fhD/7u7i5fTVzWgNRZ4ebIEGHNDbWFRxaTu1NtQgvA==}
peerDependencies: peerDependencies:
'@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2 '@simplewebauthn/server': ^9.0.2
@ -1551,8 +1551,8 @@ packages:
- utf-8-validate - utf-8-validate
dev: false dev: false
/@next/env@14.2.0-canary.23: /@next/env@14.2.0-canary.31:
resolution: {integrity: sha512-cBlFB8Y/iE3K2NX/Km4tP4RZGLsv0D72KI9KxmZepKSkaQBSbtHM0YeHnZ51CFe9UQKzQ/1mPnCY89BjiyIQtQ==} resolution: {integrity: sha512-xdUjSv8c5e1QPiB010TcyW1zPL3bK7FySHQDu6NjzZuUkYwm8W9c9NGIdJLB2UQv0rfpaFBKfWNlGbakicrE+g==}
dev: false dev: false
/@next/eslint-plugin-next@14.1.3: /@next/eslint-plugin-next@14.1.3:
@ -1561,8 +1561,8 @@ packages:
glob: 10.3.10 glob: 10.3.10
dev: false dev: false
/@next/swc-darwin-arm64@14.2.0-canary.23: /@next/swc-darwin-arm64@14.2.0-canary.31:
resolution: {integrity: sha512-K1f7A/0ljZO7IX+M+phguFP8lxdqMgEv1x1+gC+UmyZ1c8Na1PgirGgUwdLKxuNjyxlRqY5lkQ/FDk2FOYBSLA==} resolution: {integrity: sha512-9NRPNOWxY/ecv1hxZej9nVvggIemdCqkwlgmkVv+M2TkAJzff5bwY4IzTuyPxQmioxHs43gPyKh/wpVsH4cuog==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
@ -1570,8 +1570,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-darwin-x64@14.2.0-canary.23: /@next/swc-darwin-x64@14.2.0-canary.31:
resolution: {integrity: sha512-3Zg0aZZV9Z0+4QCVNMH/LLW1dRD2mVNtuFBoTI6/7rWUiGrm/9+58sKyjjg+cE9S/zAKvJoFZP7Ask9vnrk4tg==} resolution: {integrity: sha512-8xbOircQJzJx39GZ4iNd/6PIyOI/qZ8TpjJ9qzA1FpVZUDAMXZIDnQIHIsnxvn0HkP85PByw3tcKZLLKcw5k4g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
@ -1579,8 +1579,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-arm64-gnu@14.2.0-canary.23: /@next/swc-linux-arm64-gnu@14.2.0-canary.31:
resolution: {integrity: sha512-OMt5uTXtEZNKaeSvviJQVXzARr3Jyk1BKUQVD10hKUa4edWBcmnrpdqiDVoDCGt9kMOdKdGqJHOJUk/jV/G15w==} resolution: {integrity: sha512-zpA19+op2KxnIutpoZbvQiplmoJHsUUPIhynC+PpZtSqA1IWBZoem+T4IhnmHr+F0DQZRqO9lmZZkHdEpRAzbQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -1588,8 +1588,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-arm64-musl@14.2.0-canary.23: /@next/swc-linux-arm64-musl@14.2.0-canary.31:
resolution: {integrity: sha512-vllciUQ4U99LCOBnsFt9QGf9AyE8yhBtNdNxbij5QsdQ/F53SamxrbYOgG7RisvRqFmWSQMfHdpGZOE0EUUsvQ==} resolution: {integrity: sha512-cZ1GuBi6YOHdEw8/lhuxZsObJVpry7irgf1PCsOLigpIqCAyMhbAcQ32FkTA3N5MKWpOkrmS9xQ2B6K1ZaQzCg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -1597,8 +1597,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-x64-gnu@14.2.0-canary.23: /@next/swc-linux-x64-gnu@14.2.0-canary.31:
resolution: {integrity: sha512-soKCxTCi0m0hOBSEchH2YTluvQzAEb8HIoQXxligU8C1fmFDX25pwQ4iSWmdvA6xDJn96PG2R64NyytTTMg9TQ==} resolution: {integrity: sha512-YxWC/fipzs/3cTeGcsSSU3GXEAJ16TI3yo4jgwofWko2jSF2/kpCOZSnRYbqX3eNyBCD4K9/g/9v6rAx2zHiew==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -1606,8 +1606,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-x64-musl@14.2.0-canary.23: /@next/swc-linux-x64-musl@14.2.0-canary.31:
resolution: {integrity: sha512-Qr+4ySSYEh1hSSmUJ50oHtKopkqwo3RFb2CXpzcMqp+6U8WOMYBX+JTSCFdA3lSlUJqScIgoRoMDk9I3TZM71g==} resolution: {integrity: sha512-CoZVZyx08myeDXmoSEuk+eXrwMYenevXP03Rz/+6+BT6zOrq+1s1rFKUZVd6/3AnD9Q7vNCuTGtaL5jaF5koGw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -1615,8 +1615,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-win32-arm64-msvc@14.2.0-canary.23: /@next/swc-win32-arm64-msvc@14.2.0-canary.31:
resolution: {integrity: sha512-94HGrBqz9s7z6d6hlukbkF4LiU4Bw+a+C3i+J7pBA+3BHtIyffuqmnrP3HY92trP3M328GBN01H7/zqahcvwPQ==} resolution: {integrity: sha512-m1nuZmu8DOJKJvVrrz7KxMH1K3IU1UC7av1jD55cFf3ZM5ur06Mx2PvtbKSnSLCjK7Ga8LHMYXBXQWAbkD6Bcg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
@ -1624,8 +1624,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-win32-ia32-msvc@14.2.0-canary.23: /@next/swc-win32-ia32-msvc@14.2.0-canary.31:
resolution: {integrity: sha512-lrvn6ekxPyrBidQxA0kE3xyys8fCLlbhi1tl2FA0QUPHvgPbu2127Q7+UFnl/NGC9veZfCbw6b7TVFyvPseRsQ==} resolution: {integrity: sha512-OFmjN8wK6eSViHUqsh7VLzI8H8d3G6esjn3zOHoSirg821MJLyWVGAXMBjykDz4kTP6VmGdCkohBP6nf/uy94Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
@ -1633,8 +1633,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-win32-x64-msvc@14.2.0-canary.23: /@next/swc-win32-x64-msvc@14.2.0-canary.31:
resolution: {integrity: sha512-T8zZcK8M5GYWzIKOlxcothb0sixdFSowUztZTNb/kmUK8Vz1Z+TFBxU00WvBDx9ymrGP2f4EKL9YQqu2d+BmcA==} resolution: {integrity: sha512-Dv+FC2zYh8aEKsFUpq6815grRS0dcRw9uJ9hxULAZ9EuFcw0iu5zKbEPWZ+klxDrAWA8bw7WYMZfkvEH7bSLOA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -3088,7 +3088,7 @@ packages:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: false dev: false
/@vercel/analytics@1.2.2(next@14.2.0-canary.23)(react@18.2.0): /@vercel/analytics@1.2.2(next@14.2.0-canary.31)(react@18.2.0):
resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==} resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==}
peerDependencies: peerDependencies:
next: '>= 13' next: '>= 13'
@ -3099,7 +3099,7 @@ packages:
react: react:
optional: true optional: true
dependencies: dependencies:
next: 14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0 react: 18.2.0
server-only: 0.0.1 server-only: 0.0.1
dev: false dev: false
@ -3124,7 +3124,7 @@ packages:
ws: 8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3) ws: 8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3)
dev: false dev: false
/@vercel/speed-insights@1.0.10(next@14.2.0-canary.23)(react@18.2.0): /@vercel/speed-insights@1.0.10(next@14.2.0-canary.31)(react@18.2.0):
resolution: {integrity: sha512-4uzdKB0RW6Ff2FkzshzjZ+RlJfLPxgm/00i0XXgxfMPhwnnsk92YgtqsxT9OcPLdJUyVU1DqFlSWWjIQMPkh0g==} resolution: {integrity: sha512-4uzdKB0RW6Ff2FkzshzjZ+RlJfLPxgm/00i0XXgxfMPhwnnsk92YgtqsxT9OcPLdJUyVU1DqFlSWWjIQMPkh0g==}
requiresBuild: true requiresBuild: true
peerDependencies: peerDependencies:
@ -3148,7 +3148,7 @@ packages:
vue-router: vue-router:
optional: true optional: true
dependencies: dependencies:
next: 14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0 react: 18.2.0
dev: false dev: false
@ -6057,8 +6057,8 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: false dev: false
/next-auth@5.0.0-beta.13(next@14.2.0-canary.23)(react@18.2.0): /next-auth@5.0.0-beta.15(next@14.2.0-canary.31)(react@18.2.0):
resolution: {integrity: sha512-2m2Gq69WQ0YXcHCCpHn2y5z1bxSlqD/XOuAgrdtz49/VIAdTFFeYZz97RYqf6xMF8VGmoG32VUnJ6LzaHk6Fwg==} resolution: {integrity: sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==}
peerDependencies: peerDependencies:
'@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2 '@simplewebauthn/server': ^9.0.2
@ -6073,8 +6073,8 @@ packages:
nodemailer: nodemailer:
optional: true optional: true
dependencies: dependencies:
'@auth/core': 0.27.0 '@auth/core': 0.28.0
next: 14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0 react: 18.2.0
dev: false dev: false
@ -6088,8 +6088,8 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/next@14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0): /next@14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-HuQYIeSlmBmTueVIxn0Zjxx5I4MD6vG3p6AsFySTF7/hMSF5qFUCHGIj3YJRWsDcUw/LQhuGPNLaGUg119YegQ==} resolution: {integrity: sha512-aKls3+4raMbu8ex6YDuQFa8U4ajKojXpn8GvdlFtgNUyBHJ7IrVuaFJw6rU9s9OibfgpFnAbsmvKzYQmAAjmlg==}
engines: {node: '>=18.17.0'} engines: {node: '>=18.17.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -6103,7 +6103,7 @@ packages:
sass: sass:
optional: true optional: true
dependencies: dependencies:
'@next/env': 14.2.0-canary.23 '@next/env': 14.2.0-canary.31
'@swc/helpers': 0.5.5 '@swc/helpers': 0.5.5
busboy: 1.6.0 busboy: 1.6.0
caniuse-lite: 1.0.30001591 caniuse-lite: 1.0.30001591
@ -6113,15 +6113,15 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(@babel/core@7.23.9)(react@18.2.0) styled-jsx: 5.1.1(@babel/core@7.23.9)(react@18.2.0)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 14.2.0-canary.23 '@next/swc-darwin-arm64': 14.2.0-canary.31
'@next/swc-darwin-x64': 14.2.0-canary.23 '@next/swc-darwin-x64': 14.2.0-canary.31
'@next/swc-linux-arm64-gnu': 14.2.0-canary.23 '@next/swc-linux-arm64-gnu': 14.2.0-canary.31
'@next/swc-linux-arm64-musl': 14.2.0-canary.23 '@next/swc-linux-arm64-musl': 14.2.0-canary.31
'@next/swc-linux-x64-gnu': 14.2.0-canary.23 '@next/swc-linux-x64-gnu': 14.2.0-canary.31
'@next/swc-linux-x64-musl': 14.2.0-canary.23 '@next/swc-linux-x64-musl': 14.2.0-canary.31
'@next/swc-win32-arm64-msvc': 14.2.0-canary.23 '@next/swc-win32-arm64-msvc': 14.2.0-canary.31
'@next/swc-win32-ia32-msvc': 14.2.0-canary.23 '@next/swc-win32-ia32-msvc': 14.2.0-canary.31
'@next/swc-win32-x64-msvc': 14.2.0-canary.23 '@next/swc-win32-x64-msvc': 14.2.0-canary.31
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros

View File

@ -45,7 +45,7 @@ export default async function GridPage() {
/> />
</Suspense> </Suspense>
</div>} </div>}
contentSide={<div className="sticky top-4 space-y-4"> contentSide={<div className="sticky top-4 space-y-4 mt-[-4px]">
<PhotoGridSidebar {...{ <PhotoGridSidebar {...{
tags, tags,
cameras, cameras,

View File

@ -1,45 +0,0 @@
import { getPhotosCached } from '@/photo/cache';
import InfoBlock from '@/components/InfoBlock';
import RedirectOnDesktop from '@/components/RedirectOnDesktop';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos } from '@/photo';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarDataCached } from '@/photo/data';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import { PATH_GRID } from '@/site/paths';
import { Metadata } from 'next/types';
export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG });
return generateOgImageMetaForPhotos(photos);
}
export default async function SetsPage() {
const [
photosCount,
tags,
cameras,
simulations,
] = await Promise.all(getPhotoSidebarDataCached());
return (
<SiteGrid
contentMain={<InfoBlock
padding="tight"
centered={false}
>
<RedirectOnDesktop redirectPath={PATH_GRID} />
<div className="text-base space-y-4 p-2">
<PhotoGridSidebar {...{
tags,
cameras,
simulations,
photosCount,
}} />
</div>
</InfoBlock>}
/>
);
}

View File

@ -44,6 +44,17 @@ export const {
}, },
}); });
export const safelyRunServerAdminAction = async <T>(
callback: () => T,
): Promise<T> => {
const session = await auth();
if (session?.user) {
return callback();
} else {
throw new Error('Unauthorized server action request');
}
};
export const generateAuthSecret = () => fetch( export const generateAuthSecret = () => fetch(
'https://generate-secret.vercel.app/32', 'https://generate-secret.vercel.app/32',
{ cache: 'no-cache' }, { cache: 'no-cache' },

View File

@ -3,7 +3,6 @@ import { pathForCamera } from '@/site/paths';
import { IoMdCamera } from 'react-icons/io'; import { IoMdCamera } from 'react-icons/io';
import { Camera, formatCameraText } from '.'; import { Camera, formatCameraText } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink'; import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import { clsx } from 'clsx/lite';
export default function PhotoCamera({ export default function PhotoCamera({
camera, camera,
@ -28,18 +27,12 @@ export default function PhotoCamera({
icon={showAppleIcon icon={showAppleIcon
? <AiFillApple ? <AiFillApple
title="Apple" title="Apple"
className={clsx( className="translate-x-[-2.5px] translate-y-[2px]"
'text-icon',
'translate-x-[-2.5px] translate-y-[2px]',
)}
size={15} size={15}
/> />
: <IoMdCamera : <IoMdCamera
size={13} size={12}
className={clsx( className="translate-x-[-1px] translate-y-[3.5px]"
'text-icon',
'translate-x-[-1px] translate-y-[3.5px]',
)}
/>} />}
type={showAppleIcon && isCameraApple ? 'icon-first' : type} type={showAppleIcon && isCameraApple ? 'icon-first' : type}
badged={badged} badged={badged}

View File

@ -18,8 +18,8 @@ export default function EntityLink({
title, title,
type = 'icon-first', type = 'icon-first',
badged, badged,
contrast,
prefetch, prefetch,
contrast = 'high',
hoverEntity, hoverEntity,
}: { }: {
label: ReactNode label: ReactNode
@ -38,15 +38,26 @@ export default function EntityLink({
</span> </span>
</>; </>;
const classForContrast = () => {
switch (contrast) {
case 'low':
return 'text-dim';
case 'high':
return 'text-main';
default:
return 'text-medium';
}
};
return ( return (
<span className="group inline-flex items-center gap-2"> <span className="group inline-flex items-center gap-2 h-5">
<Link <Link
href={href} href={href}
title={title} title={title}
className={clsx( className={clsx(
'inline-flex gap-[0.23rem]', 'inline-flex gap-[0.23rem]',
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100', !badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
contrast === 'low' && 'text-dim', classForContrast(),
)} )}
prefetch={prefetch} prefetch={prefetch}
> >
@ -70,7 +81,9 @@ export default function EntityLink({
<span className={clsx( <span className={clsx(
'flex-shrink-0', 'flex-shrink-0',
'inline-flex min-w-[0.9rem]', 'inline-flex min-w-[0.9rem]',
contrast === 'low' ? 'text-dim' : 'text-main', contrast === 'high'
? 'text-icon'
: classForContrast(),
type === 'icon-first' && 'order-first', type === 'icon-first' && 'order-first',
badged && 'translate-y-[4px]', badged && 'translate-y-[4px]',
hoverEntity !== undefined && 'group-hover:hidden', hoverEntity !== undefined && 'group-hover:hidden',

View File

@ -15,7 +15,10 @@ export default function HeaderList({
}) { }) {
return ( return (
<AnimateItems <AnimateItems
className={className} className={clsx(
className,
'space-y-0.5',
)}
scaleOffset={0.95} scaleOffset={0.95}
duration={0.5} duration={0.5}
staggerDelay={0.05} staggerDelay={0.05}
@ -25,7 +28,7 @@ export default function HeaderList({
className={clsx( className={clsx(
'text-gray-900', 'text-gray-900',
'dark:text-gray-100', 'dark:text-gray-100',
'flex items-center mb-0.5 gap-1', 'flex items-center mb-1 gap-1',
'uppercase', 'uppercase',
)} )}
> >

View File

@ -37,6 +37,7 @@ export default function PhotoGridSidebar({
countOnHover={count} countOnHover={count}
type="icon-last" type="icon-last"
prefetch={false} prefetch={false}
contrast="low"
badged badged
/> />
: <PhotoTag : <PhotoTag
@ -45,6 +46,7 @@ export default function PhotoGridSidebar({
type="text-only" type="text-only"
countOnHover={count} countOnHover={count}
prefetch={false} prefetch={false}
contrast="low"
badged badged
/>)} />)}
/>} />}
@ -63,6 +65,7 @@ export default function PhotoGridSidebar({
type="text-only" type="text-only"
countOnHover={count} countOnHover={count}
prefetch={false} prefetch={false}
contrast="low"
hideAppleIcon hideAppleIcon
badged badged
/>)} />)}

View File

@ -47,15 +47,9 @@ export default function PhotoLarge({
const camera = cameraFromPhoto(photo); const camera = cameraFromPhoto(photo);
const renderMiniGrid = (children: JSX.Element, rightPadding = true) => const showCameraContent = showCamera && shouldShowCameraDataForPhoto(photo);
<div className={clsx( const showTagsContent = tags.length > 0;
'flex gap-y-4', const showExifContent = shouldShowExifDataForPhoto(photo);
'flex-col sm:flex-row md:flex-col',
'[&>*]:sm:flex-grow',
rightPadding && 'pr-2',
)}>
{children}
</div>;
return ( return (
<SiteGrid <SiteGrid
@ -76,82 +70,81 @@ export default function PhotoLarge({
</Link>} </Link>}
contentSide={ contentSide={
<div className={clsx( <div className={clsx(
'relative',
'leading-snug', 'leading-snug',
'sticky top-4 self-start', 'sticky top-4 self-start -translate-y-1',
'grid grid-cols-2 md:grid-cols-1', 'grid grid-cols-2 md:grid-cols-1',
'gap-x-0.5 sm:gap-x-1', 'gap-x-0.5 sm:gap-x-1 gap-y-4',
'gap-y-4', 'pb-6',
'-translate-y-1',
'mb-4',
)}> )}>
{renderMiniGrid(<> {/* Meta */}
<div className="-space-y-0.5"> <div className="pr-3 md:pr-0">
<div className="relative flex gap-2 items-start"> <div className="md:relative flex gap-2 items-start">
<div className="md:flex-grow"> <div className="flex-grow">
<Link <Link
href={pathForPhoto(photo)} href={pathForPhoto(photo)}
className="font-bold uppercase" className="font-bold uppercase"
prefetch={prefetch} >
> {titleForPhoto(photo)}
{titleForPhoto(photo)} </Link>
</Link>
</div>
<Suspense>
<div className="h-4 translate-y-[-3.5px] z-10">
<AdminPhotoMenu photo={photo} />
</div>
</Suspense>
</div> </div>
{tags.length > 0 && <Suspense>
<PhotoTags <div className="absolute right-0 translate-y-[-4px] z-10">
tags={tags} <AdminPhotoMenu photo={photo} />
prefetch={prefetchRelatedLinks} </div>
/>} </Suspense>
</div> </div>
{showCamera && shouldShowCameraDataForPhoto(photo) && <div className="space-y-4">
<div className="space-y-0.5"> {photo.caption &&
<PhotoCamera <div className="uppercase">
camera={camera} {photo.caption}
type="text-only" </div>}
prefetch={prefetchRelatedLinks} {(showCameraContent || showTagsContent) &&
/> <div>
{showCameraContent &&
<PhotoCamera
camera={camera}
contrast="medium"
/>}
{showTagsContent &&
<PhotoTags tags={tags} contrast="medium" />}
</div>}
</div>
</div>
{/* EXIF Data */}
<div className="space-y-4">
{showExifContent &&
<>
<ul className="text-medium">
<li>
{photo.focalLengthFormatted}
{photo.focalLengthIn35MmFormatFormatted &&
<>
{' '}
<span
title="35mm equivalent"
className="text-extra-dim"
>
{photo.focalLengthIn35MmFormatFormatted}
</span>
</>}
</li>
<li>{photo.fNumberFormatted}</li>
<li>{photo.exposureTimeFormatted}</li>
<li>{photo.isoFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
</ul>
{showSimulation && photo.filmSimulation && {showSimulation && photo.filmSimulation &&
<div className="translate-x-[-0.3rem]"> <PhotoFilmSimulation
<PhotoFilmSimulation simulation={photo.filmSimulation}
simulation={photo.filmSimulation} />}
prefetch={prefetchRelatedLinks} </>}
/>
</div>}
</div>}
</>)}
{renderMiniGrid(<>
{shouldShowExifDataForPhoto(photo) &&
<ul className="text-medium">
<li>
{photo.focalLengthFormatted}
{photo.focalLengthIn35MmFormatFormatted &&
<>
{' '}
<span
title="35mm equivalent"
className="text-extra-dim"
>
{photo.focalLengthIn35MmFormatFormatted}
</span>
</>}
</li>
<li>{photo.fNumberFormatted}</li>
<li>{photo.exposureTimeFormatted}</li>
<li>{photo.isoFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '—'}</li>
</ul>}
<div className={clsx( <div className={clsx(
'flex gap-y-4', 'flex gap-2',
'flex-col sm:flex-row md:flex-col', 'md:flex-col md:gap-4 md:justify-normal',
)}> )}>
<div className={clsx( <div className={clsx(
'grow uppercase', 'text-medium uppercase pr-1',
'text-medium',
)}> )}>
{photo.takenAtNaiveFormatted} {photo.takenAtNaiveFormatted}
</div> </div>
@ -166,7 +159,7 @@ export default function PhotoLarge({
shouldScroll={shouldScrollOnShare} shouldScroll={shouldScrollOnShare}
/> />
</div> </div>
</>, false)} </div>
</div>} </div>}
/> />
); );

View File

@ -34,51 +34,58 @@ import {
} from '@/site/paths'; } from '@/site/paths';
import { extractExifDataFromBlobPath } from './server'; import { extractExifDataFromBlobPath } from './server';
import { TAG_FAVS, isTagFavs } from '@/tag'; import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, titleForPhoto } from '.';
import { TbPhoto } from 'react-icons/tb'; import { TbPhoto } from 'react-icons/tb';
import PhotoTiny from './PhotoTiny'; import PhotoTiny from './PhotoTiny';
import { formatDate } from '@/utility/date'; import { formatDate } from '@/utility/date';
import { convertPhotoToPhotoDbInsert, titleForPhoto } from '.';
import { safelyRunServerAdminAction } from '@/auth';
export async function createPhotoAction(formData: FormData) { export async function createPhotoAction(formData: FormData) {
const photo = convertFormDataToPhotoDbInsert(formData, true); return safelyRunServerAdminAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData, true);
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
if (updatedUrl) { photo.url = updatedUrl; } if (updatedUrl) { photo.url = updatedUrl; }
await sqlInsertPhoto(photo); await sqlInsertPhoto(photo);
revalidateAllKeysAndPaths(); revalidateAllKeysAndPaths();
redirect(PATH_ADMIN_PHOTOS); redirect(PATH_ADMIN_PHOTOS);
});
} }
export async function updatePhotoAction(formData: FormData) { export async function updatePhotoAction(formData: FormData) {
const photo = convertFormDataToPhotoDbInsert(formData); return safelyRunServerAdminAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData);
await sqlUpdatePhoto(photo); await sqlUpdatePhoto(photo);
revalidatePhoto(photo.id); revalidatePhoto(photo.id);
redirect(PATH_ADMIN_PHOTOS); redirect(PATH_ADMIN_PHOTOS);
});
} }
export async function toggleFavoritePhotoAction( export async function toggleFavoritePhotoAction(
photoId: string, photoId: string,
shouldRedirect?: boolean, shouldRedirect?: boolean,
) { ) {
const photo = await getPhoto(photoId); return safelyRunServerAdminAction(async () => {
if (photo) { const photo = await getPhoto(photoId);
const { tags } = photo; if (photo) {
photo.tags = tags.some(tag => tag === TAG_FAVS) const { tags } = photo;
? tags.filter(tag => !isTagFavs(tag)) photo.tags = tags.some(tag => tag === TAG_FAVS)
: [...tags, TAG_FAVS]; ? tags.filter(tag => !isTagFavs(tag))
await sqlUpdatePhoto(convertPhotoToPhotoDbInsert(photo)); : [...tags, TAG_FAVS];
revalidateAllKeysAndPaths(); await sqlUpdatePhoto(convertPhotoToPhotoDbInsert(photo));
if (shouldRedirect) { revalidateAllKeysAndPaths();
redirect(pathForPhoto(photoId)); if (shouldRedirect) {
redirect(pathForPhoto(photoId));
}
} }
} });
} }
export async function deletePhotoAction( export async function deletePhotoAction(
@ -86,84 +93,98 @@ export async function deletePhotoAction(
photoUrl: string, photoUrl: string,
shouldRedirect?: boolean, shouldRedirect?: boolean,
) { ) {
await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl)); return safelyRunServerAdminAction(async () => {
revalidateAllKeysAndPaths(); await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
if (shouldRedirect) { revalidateAllKeysAndPaths();
redirect(PATH_ROOT); if (shouldRedirect) {
} redirect(PATH_ROOT);
}
});
}; };
export async function deletePhotoFormAction(formData: FormData) { export async function deletePhotoFormAction(formData: FormData) {
return deletePhotoAction( return safelyRunServerAdminAction(async () =>
formData.get('id') as string, deletePhotoAction(
formData.get('url') as string, formData.get('id') as string,
formData.get('url') as string,
)
); );
}; };
export async function deletePhotoTagGloballyAction(formData: FormData) { export async function deletePhotoTagGloballyAction(formData: FormData) {
const tag = formData.get('tag') as string; return safelyRunServerAdminAction(async () => {
const tag = formData.get('tag') as string;
await sqlDeletePhotoTagGlobally(tag); await sqlDeletePhotoTagGlobally(tag);
revalidatePhotosKey(); revalidatePhotosKey();
revalidateAdminPaths(); revalidateAdminPaths();
});
} }
export async function renamePhotoTagGloballyAction(formData: FormData) { export async function renamePhotoTagGloballyAction(formData: FormData) {
const tag = formData.get('tag') as string; return safelyRunServerAdminAction(async () => {
const updatedTag = formData.get('updatedTag') as string; const tag = formData.get('tag') as string;
const updatedTag = formData.get('updatedTag') as string;
if (tag && updatedTag && tag !== updatedTag) { if (tag && updatedTag && tag !== updatedTag) {
await sqlRenamePhotoTagGlobally(tag, updatedTag); await sqlRenamePhotoTagGlobally(tag, updatedTag);
revalidatePhotosKey(); revalidatePhotosKey();
revalidateTagsKey(); revalidateTagsKey();
redirect(PATH_ADMIN_TAGS); redirect(PATH_ADMIN_TAGS);
} }
});
} }
export async function deleteBlobPhotoAction(formData: FormData) { export async function deleteBlobPhotoAction(formData: FormData) {
await deleteStorageUrl(formData.get('url') as string); return safelyRunServerAdminAction(async () => {
await deleteStorageUrl(formData.get('url') as string);
revalidateAdminPaths(); revalidateAdminPaths();
if (formData.get('redirectToPhotos') === 'true') { if (formData.get('redirectToPhotos') === 'true') {
redirect(PATH_ADMIN_PHOTOS); redirect(PATH_ADMIN_PHOTOS);
} }
});
} }
export async function getExifDataAction( export async function getExifDataAction(
photoFormPrevious: Partial<PhotoFormData>, photoFormPrevious: Partial<PhotoFormData>,
): Promise<Partial<PhotoFormData>> { ): Promise<Partial<PhotoFormData>> {
const { url } = photoFormPrevious; return safelyRunServerAdminAction(async () => {
if (url) { const { url } = photoFormPrevious;
const { photoFormExif } = await extractExifDataFromBlobPath(url); if (url) {
if (photoFormExif) { const { photoFormExif } = await extractExifDataFromBlobPath(url);
return photoFormExif; if (photoFormExif) {
return photoFormExif;
}
} }
} return {};
return {}; });
} }
export async function syncPhotoExifDataAction(formData: FormData) { export async function syncPhotoExifDataAction(formData: FormData) {
const photoId = formData.get('id') as string; return safelyRunServerAdminAction(async () => {
if (photoId) { const photoId = formData.get('id') as string;
const photo = await getPhoto(photoId); if (photoId) {
if (photo) { const photo = await getPhoto(photoId);
const { photoFormExif } = await extractExifDataFromBlobPath(photo.url); if (photo) {
if (photoFormExif) { const { photoFormExif } = await extractExifDataFromBlobPath(photo.url);
const photoFormDbInsert = convertFormDataToPhotoDbInsert({ if (photoFormExif) {
...convertPhotoToFormData(photo), const photoFormDbInsert = convertFormDataToPhotoDbInsert({
...photoFormExif, ...convertPhotoToFormData(photo),
}); ...photoFormExif,
await sqlUpdatePhoto(photoFormDbInsert); });
revalidatePhotosKey(); await sqlUpdatePhoto(photoFormDbInsert);
revalidatePhotosKey();
}
} }
} }
} });
} }
export async function syncCacheAction() { export async function syncCacheAction() {
revalidateAllKeysAndPaths(); return safelyRunServerAdminAction(revalidateAllKeysAndPaths);
} }
export async function getPhotoItemsAction(query: string) { export async function getPhotoItemsAction(query: string) {

View File

@ -168,15 +168,16 @@ export default function PhotoForm({
tagOptions, tagOptions,
readOnly, readOnly,
validate, validate,
validateStringMaxLength,
capitalize, capitalize,
hideIfEmpty, hideIfEmpty,
hideBasedOnCamera, shouldHide,
loadingMessage, loadingMessage,
type, type,
}]) => }]) =>
( (
(!hideIfEmpty || formData[key]) && (!hideIfEmpty || formData[key]) &&
!hideBasedOnCamera?.(formData.make) !shouldHide?.(formData)
) && ) &&
<FieldSetWithStatus <FieldSetWithStatus
key={key} key={key}
@ -189,6 +190,13 @@ export default function PhotoForm({
setFormData({ ...formData, [key]: value }); setFormData({ ...formData, [key]: value });
if (validate) { if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) }); setFormErrors({ ...formErrors, [key]: validate(value) });
} else if (validateStringMaxLength !== undefined) {
setFormErrors({
...formErrors,
[key]: value.length > validateStringMaxLength
? `${validateStringMaxLength} characters or less`
: undefined,
});
} }
if (key === 'title') { if (key === 'title') {
onTitleChange?.(value.trim()); onTitleChange?.(value.trim());

View File

@ -39,10 +39,11 @@ type FormMeta = {
virtual?: boolean virtual?: boolean
readOnly?: boolean readOnly?: boolean
validate?: (value?: string) => string | undefined validate?: (value?: string) => string | undefined
validateStringMaxLength?: number
capitalize?: boolean capitalize?: boolean
hide?: boolean hide?: boolean
hideIfEmpty?: boolean hideIfEmpty?: boolean
hideBasedOnCamera?: (make?: string, mode?: string) => boolean shouldHide?: (formData: Partial<PhotoFormData>) => boolean
loadingMessage?: string loadingMessage?: string
type?: FieldSetType type?: FieldSetType
selectOptions?: { value: string, label: string }[] selectOptions?: { value: string, label: string }[]
@ -50,10 +51,29 @@ type FormMeta = {
tagOptions?: AnnotatedTag[] tagOptions?: AnnotatedTag[]
}; };
const STRING_MAX_LENGTH_SHORT = 255;
const STRING_MAX_LENGTH_LONG = 1000;
const FORM_METADATA = ( const FORM_METADATA = (
tagOptions?: AnnotatedTag[] tagOptions?: AnnotatedTag[]
): Record<keyof PhotoFormData, FormMeta> => ({ ): Record<keyof PhotoFormData, FormMeta> => ({
title: { label: 'title', capitalize: true }, title: {
label: 'title',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
},
caption: {
label: 'caption',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
shouldHide: ({ title, caption }) => !title && !caption,
},
semanticDescription: {
label: 'semantic description',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
hide: true,
},
tags: { tags: {
label: 'tags', label: 'tags',
tagOptions, tagOptions,
@ -78,7 +98,7 @@ const FORM_METADATA = (
label: 'fujifilm simulation', label: 'fujifilm simulation',
selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS, selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
selectOptionsDefaultLabel: 'Unknown', selectOptionsDefaultLabel: 'Unknown',
hideBasedOnCamera: make => make !== MAKE_FUJIFILM, shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
}, },
focalLength: { label: 'focal length' }, focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
@ -116,9 +136,12 @@ export const getFormErrors = (
export const isFormValid = (formData: Partial<PhotoFormData>) => export const isFormValid = (formData: Partial<PhotoFormData>) =>
FORM_METADATA_ENTRIES().every( FORM_METADATA_ENTRIES().every(
([key, { required, validate }]) => ([key, { required, validate, validateStringMaxLength }]) =>
(!required || Boolean(formData[key])) && (!required || Boolean(formData[key])) &&
(validate?.(formData[key]) === undefined) (validate?.(formData[key]) === undefined) &&
// eslint-disable-next-line max-len
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) &&
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
); );
// CREATE FORM DATA: FROM PHOTO // CREATE FORM DATA: FROM PHOTO

View File

@ -55,6 +55,8 @@ export interface PhotoDbInsert extends PhotoExif {
extension: string extension: string
blurData?: string blurData?: string
title?: string title?: string
caption?: string
semanticDescription?: string
tags?: string[] tags?: string[]
locationName?: string locationName?: string
priorityOrder?: number priorityOrder?: number
@ -237,16 +239,16 @@ export const dateRangeForPhotos = (
}; };
const photoHasCameraData = (photo: Photo) => const photoHasCameraData = (photo: Photo) =>
photo.make && Boolean(photo.make) &&
photo.model; Boolean(photo.model);
const photoHasExifData = (photo: Photo) => const photoHasExifData = (photo: Photo) =>
photo.focalLength || Boolean(photo.focalLength) ||
photo.focalLengthIn35MmFormat || Boolean(photo.focalLengthIn35MmFormat) ||
photo.fNumberFormatted || Boolean(photo.fNumberFormatted) ||
photo.isoFormatted || Boolean(photo.isoFormatted) ||
photo.exposureTimeFormatted || Boolean(photo.exposureTimeFormatted) ||
photo.exposureCompensationFormatted; Boolean(photo.exposureCompensationFormatted);
export const shouldShowCameraDataForPhoto = (photo: Photo) => export const shouldShowCameraDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA && photoHasCameraData(photo); SHOW_EXIF_DATA && photoHasCameraData(photo);

View File

@ -29,6 +29,8 @@ const sqlCreatePhotosTable = () =>
aspect_ratio REAL DEFAULT 1.5, aspect_ratio REAL DEFAULT 1.5,
blur_data TEXT, blur_data TEXT,
title VARCHAR(255), title VARCHAR(255),
caption TEXT,
semantic_description TEXT,
tags VARCHAR(255)[], tags VARCHAR(255)[],
make VARCHAR(255), make VARCHAR(255),
model VARCHAR(255), model VARCHAR(255),
@ -51,9 +53,18 @@ const sqlCreatePhotosTable = () =>
) )
`; `;
// Migration 01
const MIGRATION_FIELDS_01 = ['caption', 'semantic_description'];
const sqlRunMigration01 = () =>
sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS caption TEXT,
ADD COLUMN IF NOT EXISTS semantic_description TEXT
`;
// Must provide id as 8-character nanoid // Must provide id as 8-character nanoid
export const sqlInsertPhoto = (photo: PhotoDbInsert) => { export const sqlInsertPhoto = (photo: PhotoDbInsert) =>
return sql` safelyQueryPhotos(() => sql`
INSERT INTO photos ( INSERT INTO photos (
id, id,
url, url,
@ -61,6 +72,8 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
aspect_ratio, aspect_ratio,
blur_data, blur_data,
title, title,
caption,
semantic_description,
tags, tags,
make, make,
model, model,
@ -86,6 +99,8 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
${photo.aspectRatio}, ${photo.aspectRatio},
${photo.blurData}, ${photo.blurData},
${photo.title}, ${photo.title},
${photo.caption},
${photo.semanticDescription},
${convertArrayToPostgresString(photo.tags)}, ${convertArrayToPostgresString(photo.tags)},
${photo.make}, ${photo.make},
${photo.model}, ${photo.model},
@ -104,17 +119,18 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
${photo.takenAt}, ${photo.takenAt},
${photo.takenAtNaive} ${photo.takenAtNaive}
) )
`; `, 'sqlInsertPhoto');
};
export const sqlUpdatePhoto = (photo: PhotoDbInsert) => export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
sql` safelyQueryPhotos(() => sql`
UPDATE photos SET UPDATE photos SET
url=${photo.url}, url=${photo.url},
extension=${photo.extension}, extension=${photo.extension},
aspect_ratio=${photo.aspectRatio}, aspect_ratio=${photo.aspectRatio},
blur_data=${photo.blurData}, blur_data=${photo.blurData},
title=${photo.title}, title=${photo.title},
caption=${photo.caption},
semantic_description=${photo.semanticDescription},
tags=${convertArrayToPostgresString(photo.tags)}, tags=${convertArrayToPostgresString(photo.tags)},
make=${photo.make}, make=${photo.make},
model=${photo.model}, model=${photo.model},
@ -134,27 +150,33 @@ export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
taken_at_naive=${photo.takenAtNaive}, taken_at_naive=${photo.takenAtNaive},
updated_at=${(new Date()).toISOString()} updated_at=${(new Date()).toISOString()}
WHERE id=${photo.id} WHERE id=${photo.id}
`; `, 'sqlUpdatePhoto');
export const sqlDeletePhotoTagGlobally = (tag: string) => export const sqlDeletePhotoTagGlobally = (tag: string) =>
sql` safelyQueryPhotos(() => sql`
UPDATE photos UPDATE photos
SET tags=ARRAY_REMOVE(tags, ${tag}) SET tags=ARRAY_REMOVE(tags, ${tag})
WHERE ${tag}=ANY(tags) WHERE ${tag}=ANY(tags)
`; `, 'sqlDeletePhotoTagGlobally');
export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) => export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) =>
sql` safelyQueryPhotos(() => sql`
UPDATE photos UPDATE photos
SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag}) SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
WHERE ${tag}=ANY(tags) WHERE ${tag}=ANY(tags)
`; `, 'sqlRenamePhotoTagGlobally');
export const sqlDeletePhoto = (id: string) => export const sqlDeletePhoto = (id: string) =>
sql`DELETE FROM photos WHERE id=${id}`; safelyQueryPhotos(
() => sql`DELETE FROM photos WHERE id=${id}`,
'sqlDeletePhoto',
);
const sqlGetPhoto = (id: string) => const sqlGetPhoto = (id: string) =>
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`; safelyQueryPhotos(
() => sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`,
'sqlGetPhoto',
);
const sqlGetPhotosCount = async () => sql` const sqlGetPhotosCount = async () => sql`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
@ -287,8 +309,16 @@ const safelyQueryPhotos = async <T>(
result = await callback(); result = await callback();
} catch (e: any) { } catch (e: any) {
screenForPPR(e, undefined, 'neon postgres'); screenForPPR(e, undefined, 'neon postgres');
if (/relation "photos" does not exist/i.test(e.message)) { if (MIGRATION_FIELDS_01.some(field => new RegExp(
console.log('Creating table "photos" because it did not exist'); `column "${field}" of relation "photos" does not exist`,
'i',
).test(e.message))) {
console.log('Running migration 01 ...');
await sqlRunMigration01();
result = await callback();
} else if (/relation "photos" does not exist/i.test(e.message)) {
// If the table does not exist, create it
console.log('Creating photos table ...');
await sqlCreatePhotosTable(); await sqlCreatePhotosTable();
result = await callback(); result = await callback();
} else if (/endpoint is in transition/i.test(e.message)) { } else if (/endpoint is in transition/i.test(e.message)) {

View File

@ -20,7 +20,7 @@ export default function PhotoTag({
href={pathForTag(tag)} href={pathForTag(tag)}
icon={<FaTag icon={<FaTag
size={11} size={11}
className="text-icon translate-y-[5px]" className="translate-y-[5px]"
/>} />}
type={type} type={type}
badged={badged} badged={badged}

View File

@ -1,21 +1,21 @@
import PhotoTag from '@/tag/PhotoTag'; import PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.'; import { isTagFavs } from '.';
import FavsTag from './FavsTag'; import FavsTag from './FavsTag';
import { EntityLinkExternalProps } from '@/components/EntityLink';
export default function PhotoTags({ export default function PhotoTags({
tags, tags,
prefetch, contrast,
}: { }: {
tags: string[] tags: string[]
prefetch?: boolean } & EntityLinkExternalProps) {
}) {
return ( return (
<div className="-space-y-0.5"> <div className="-space-y-0.5">
{tags.map(tag => {tags.map(tag =>
<div key={tag}> <div key={tag}>
{isTagFavs(tag) {isTagFavs(tag)
? <FavsTag {...{ prefetch }} /> ? <FavsTag {...{ contrast }} />
: <PhotoTag {...{ tag, prefetch }} />} : <PhotoTag {...{ tag, contrast }} />}
</div>)} </div>)}
</div> </div>
); );