diff --git a/package.json b/package.json index 3b67206a..ce562cab 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "nanoid": "^5.0.6", - "next": "14.2.0-canary.23", - "next-auth": "5.0.0-beta.13", + "next": "14.2.0-canary.31", + "next-auth": "5.0.0-beta.15", "next-themes": "^0.3.0", "postcss": "8.4.35", "react": "18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec78c9b3..8d0186e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ dependencies: version: 7.2.0(eslint@8.57.0)(typescript@5.4.2) '@vercel/analytics': 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': specifier: ^0.22.1 version: 0.22.1 @@ -58,7 +58,7 @@ dependencies: version: 0.7.2 '@vercel/speed-insights': 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: specifier: 10.4.18 version: 10.4.18(postcss@8.4.35) @@ -96,11 +96,11 @@ dependencies: specifier: ^5.0.6 version: 5.0.6 next: - specifier: 14.2.0-canary.23 - version: 14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) + specifier: 14.2.0-canary.31 + version: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) next-auth: - specifier: 5.0.0-beta.13 - version: 5.0.0-beta.13(next@14.2.0-canary.23)(react@18.2.0) + specifier: 5.0.0-beta.15 + version: 5.0.0-beta.15(next@14.2.0-canary.31)(react@18.2.0) next-themes: specifier: ^0.3.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 dev: false - /@auth/core@0.27.0: - resolution: {integrity: sha512-3bydnRJIM/Al6mkYmb53MsC+6G8ojw3lLPzwgVnX4dCo6N2lrib6Wq6r0vxZIhuHGjLObqqtUfpeaEj5aeTHFg==} + /@auth/core@0.28.0: + resolution: {integrity: sha512-/fh/tb/L4NMSYcyPoo4Imn8vN6MskcVfgESF8/ndgtI4fhD/7u7i5fTVzWgNRZ4ebIEGHNDbWFRxaTu1NtQgvA==} peerDependencies: '@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/server': ^9.0.2 @@ -1551,8 +1551,8 @@ packages: - utf-8-validate dev: false - /@next/env@14.2.0-canary.23: - resolution: {integrity: sha512-cBlFB8Y/iE3K2NX/Km4tP4RZGLsv0D72KI9KxmZepKSkaQBSbtHM0YeHnZ51CFe9UQKzQ/1mPnCY89BjiyIQtQ==} + /@next/env@14.2.0-canary.31: + resolution: {integrity: sha512-xdUjSv8c5e1QPiB010TcyW1zPL3bK7FySHQDu6NjzZuUkYwm8W9c9NGIdJLB2UQv0rfpaFBKfWNlGbakicrE+g==} dev: false /@next/eslint-plugin-next@14.1.3: @@ -1561,8 +1561,8 @@ packages: glob: 10.3.10 dev: false - /@next/swc-darwin-arm64@14.2.0-canary.23: - resolution: {integrity: sha512-K1f7A/0ljZO7IX+M+phguFP8lxdqMgEv1x1+gC+UmyZ1c8Na1PgirGgUwdLKxuNjyxlRqY5lkQ/FDk2FOYBSLA==} + /@next/swc-darwin-arm64@14.2.0-canary.31: + resolution: {integrity: sha512-9NRPNOWxY/ecv1hxZej9nVvggIemdCqkwlgmkVv+M2TkAJzff5bwY4IzTuyPxQmioxHs43gPyKh/wpVsH4cuog==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1570,8 +1570,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64@14.2.0-canary.23: - resolution: {integrity: sha512-3Zg0aZZV9Z0+4QCVNMH/LLW1dRD2mVNtuFBoTI6/7rWUiGrm/9+58sKyjjg+cE9S/zAKvJoFZP7Ask9vnrk4tg==} + /@next/swc-darwin-x64@14.2.0-canary.31: + resolution: {integrity: sha512-8xbOircQJzJx39GZ4iNd/6PIyOI/qZ8TpjJ9qzA1FpVZUDAMXZIDnQIHIsnxvn0HkP85PByw3tcKZLLKcw5k4g==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1579,8 +1579,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu@14.2.0-canary.23: - resolution: {integrity: sha512-OMt5uTXtEZNKaeSvviJQVXzARr3Jyk1BKUQVD10hKUa4edWBcmnrpdqiDVoDCGt9kMOdKdGqJHOJUk/jV/G15w==} + /@next/swc-linux-arm64-gnu@14.2.0-canary.31: + resolution: {integrity: sha512-zpA19+op2KxnIutpoZbvQiplmoJHsUUPIhynC+PpZtSqA1IWBZoem+T4IhnmHr+F0DQZRqO9lmZZkHdEpRAzbQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1588,8 +1588,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@14.2.0-canary.23: - resolution: {integrity: sha512-vllciUQ4U99LCOBnsFt9QGf9AyE8yhBtNdNxbij5QsdQ/F53SamxrbYOgG7RisvRqFmWSQMfHdpGZOE0EUUsvQ==} + /@next/swc-linux-arm64-musl@14.2.0-canary.31: + resolution: {integrity: sha512-cZ1GuBi6YOHdEw8/lhuxZsObJVpry7irgf1PCsOLigpIqCAyMhbAcQ32FkTA3N5MKWpOkrmS9xQ2B6K1ZaQzCg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1597,8 +1597,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu@14.2.0-canary.23: - resolution: {integrity: sha512-soKCxTCi0m0hOBSEchH2YTluvQzAEb8HIoQXxligU8C1fmFDX25pwQ4iSWmdvA6xDJn96PG2R64NyytTTMg9TQ==} + /@next/swc-linux-x64-gnu@14.2.0-canary.31: + resolution: {integrity: sha512-YxWC/fipzs/3cTeGcsSSU3GXEAJ16TI3yo4jgwofWko2jSF2/kpCOZSnRYbqX3eNyBCD4K9/g/9v6rAx2zHiew==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1606,8 +1606,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@14.2.0-canary.23: - resolution: {integrity: sha512-Qr+4ySSYEh1hSSmUJ50oHtKopkqwo3RFb2CXpzcMqp+6U8WOMYBX+JTSCFdA3lSlUJqScIgoRoMDk9I3TZM71g==} + /@next/swc-linux-x64-musl@14.2.0-canary.31: + resolution: {integrity: sha512-CoZVZyx08myeDXmoSEuk+eXrwMYenevXP03Rz/+6+BT6zOrq+1s1rFKUZVd6/3AnD9Q7vNCuTGtaL5jaF5koGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1615,8 +1615,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@14.2.0-canary.23: - resolution: {integrity: sha512-94HGrBqz9s7z6d6hlukbkF4LiU4Bw+a+C3i+J7pBA+3BHtIyffuqmnrP3HY92trP3M328GBN01H7/zqahcvwPQ==} + /@next/swc-win32-arm64-msvc@14.2.0-canary.31: + resolution: {integrity: sha512-m1nuZmu8DOJKJvVrrz7KxMH1K3IU1UC7av1jD55cFf3ZM5ur06Mx2PvtbKSnSLCjK7Ga8LHMYXBXQWAbkD6Bcg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1624,8 +1624,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@14.2.0-canary.23: - resolution: {integrity: sha512-lrvn6ekxPyrBidQxA0kE3xyys8fCLlbhi1tl2FA0QUPHvgPbu2127Q7+UFnl/NGC9veZfCbw6b7TVFyvPseRsQ==} + /@next/swc-win32-ia32-msvc@14.2.0-canary.31: + resolution: {integrity: sha512-OFmjN8wK6eSViHUqsh7VLzI8H8d3G6esjn3zOHoSirg821MJLyWVGAXMBjykDz4kTP6VmGdCkohBP6nf/uy94Q==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -1633,8 +1633,8 @@ packages: dev: false optional: true - /@next/swc-win32-x64-msvc@14.2.0-canary.23: - resolution: {integrity: sha512-T8zZcK8M5GYWzIKOlxcothb0sixdFSowUztZTNb/kmUK8Vz1Z+TFBxU00WvBDx9ymrGP2f4EKL9YQqu2d+BmcA==} + /@next/swc-win32-x64-msvc@14.2.0-canary.31: + resolution: {integrity: sha512-Dv+FC2zYh8aEKsFUpq6815grRS0dcRw9uJ9hxULAZ9EuFcw0iu5zKbEPWZ+klxDrAWA8bw7WYMZfkvEH7bSLOA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3088,7 +3088,7 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} 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==} peerDependencies: next: '>= 13' @@ -3099,7 +3099,7 @@ packages: react: optional: true 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 server-only: 0.0.1 dev: false @@ -3124,7 +3124,7 @@ packages: ws: 8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3) dev: false - /@vercel/speed-insights@1.0.10(next@14.2.0-canary.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==} requiresBuild: true peerDependencies: @@ -3148,7 +3148,7 @@ packages: vue-router: optional: true 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 dev: false @@ -6057,8 +6057,8 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: false - /next-auth@5.0.0-beta.13(next@14.2.0-canary.23)(react@18.2.0): - resolution: {integrity: sha512-2m2Gq69WQ0YXcHCCpHn2y5z1bxSlqD/XOuAgrdtz49/VIAdTFFeYZz97RYqf6xMF8VGmoG32VUnJ6LzaHk6Fwg==} + /next-auth@5.0.0-beta.15(next@14.2.0-canary.31)(react@18.2.0): + resolution: {integrity: sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==} peerDependencies: '@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/server': ^9.0.2 @@ -6073,8 +6073,8 @@ packages: nodemailer: optional: true dependencies: - '@auth/core': 0.27.0 - next: 14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) + '@auth/core': 0.28.0 + next: 14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 dev: false @@ -6088,8 +6088,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /next@14.2.0-canary.23(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-HuQYIeSlmBmTueVIxn0Zjxx5I4MD6vG3p6AsFySTF7/hMSF5qFUCHGIj3YJRWsDcUw/LQhuGPNLaGUg119YegQ==} + /next@14.2.0-canary.31(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-aKls3+4raMbu8ex6YDuQFa8U4ajKojXpn8GvdlFtgNUyBHJ7IrVuaFJw6rU9s9OibfgpFnAbsmvKzYQmAAjmlg==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -6103,7 +6103,7 @@ packages: sass: optional: true dependencies: - '@next/env': 14.2.0-canary.23 + '@next/env': 14.2.0-canary.31 '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001591 @@ -6113,15 +6113,15 @@ packages: react-dom: 18.2.0(react@18.2.0) styled-jsx: 5.1.1(@babel/core@7.23.9)(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.0-canary.23 - '@next/swc-darwin-x64': 14.2.0-canary.23 - '@next/swc-linux-arm64-gnu': 14.2.0-canary.23 - '@next/swc-linux-arm64-musl': 14.2.0-canary.23 - '@next/swc-linux-x64-gnu': 14.2.0-canary.23 - '@next/swc-linux-x64-musl': 14.2.0-canary.23 - '@next/swc-win32-arm64-msvc': 14.2.0-canary.23 - '@next/swc-win32-ia32-msvc': 14.2.0-canary.23 - '@next/swc-win32-x64-msvc': 14.2.0-canary.23 + '@next/swc-darwin-arm64': 14.2.0-canary.31 + '@next/swc-darwin-x64': 14.2.0-canary.31 + '@next/swc-linux-arm64-gnu': 14.2.0-canary.31 + '@next/swc-linux-arm64-musl': 14.2.0-canary.31 + '@next/swc-linux-x64-gnu': 14.2.0-canary.31 + '@next/swc-linux-x64-musl': 14.2.0-canary.31 + '@next/swc-win32-arm64-msvc': 14.2.0-canary.31 + '@next/swc-win32-ia32-msvc': 14.2.0-canary.31 + '@next/swc-win32-x64-msvc': 14.2.0-canary.31 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros diff --git a/src/app/grid/page.tsx b/src/app/grid/page.tsx index 6a139fc8..48c141f3 100644 --- a/src/app/grid/page.tsx +++ b/src/app/grid/page.tsx @@ -45,7 +45,7 @@ export default async function GridPage() { /> } - contentSide={
+ contentSide={
{ - 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 ( - - -
- -
- } - /> - ); -} diff --git a/src/auth/index.ts b/src/auth/index.ts index 53337314..4ea60774 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -44,6 +44,17 @@ export const { }, }); +export const safelyRunServerAdminAction = async ( + callback: () => T, +): Promise => { + const session = await auth(); + if (session?.user) { + return callback(); + } else { + throw new Error('Unauthorized server action request'); + } +}; + export const generateAuthSecret = () => fetch( 'https://generate-secret.vercel.app/32', { cache: 'no-cache' }, diff --git a/src/camera/PhotoCamera.tsx b/src/camera/PhotoCamera.tsx index 1724087b..ef2d9faa 100644 --- a/src/camera/PhotoCamera.tsx +++ b/src/camera/PhotoCamera.tsx @@ -3,7 +3,6 @@ import { pathForCamera } from '@/site/paths'; import { IoMdCamera } from 'react-icons/io'; import { Camera, formatCameraText } from '.'; import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink'; -import { clsx } from 'clsx/lite'; export default function PhotoCamera({ camera, @@ -28,18 +27,12 @@ export default function PhotoCamera({ icon={showAppleIcon ? : } type={showAppleIcon && isCameraApple ? 'icon-first' : type} badged={badged} diff --git a/src/components/EntityLink.tsx b/src/components/EntityLink.tsx index 806b12b6..ae2f0955 100644 --- a/src/components/EntityLink.tsx +++ b/src/components/EntityLink.tsx @@ -18,8 +18,8 @@ export default function EntityLink({ title, type = 'icon-first', badged, - contrast, prefetch, + contrast = 'high', hoverEntity, }: { label: ReactNode @@ -38,15 +38,26 @@ export default function EntityLink({ ; + const classForContrast = () => { + switch (contrast) { + case 'low': + return 'text-dim'; + case 'high': + return 'text-main'; + default: + return 'text-medium'; + } + }; + return ( - + @@ -70,7 +81,9 @@ export default function EntityLink({ diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 93a40327..09b1a6c0 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -37,6 +37,7 @@ export default function PhotoGridSidebar({ countOnHover={count} type="icon-last" prefetch={false} + contrast="low" badged /> : )} />} @@ -63,6 +65,7 @@ export default function PhotoGridSidebar({ type="text-only" countOnHover={count} prefetch={false} + contrast="low" hideAppleIcon badged />)} diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 432ae85d..b5f215c5 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -46,16 +46,10 @@ export default function PhotoLarge({ const tags = sortTags(photo.tags, primaryTag); const camera = cameraFromPhoto(photo); - - const renderMiniGrid = (children: JSX.Element, rightPadding = true) => -
*]:sm:flex-grow', - rightPadding && 'pr-2', - )}> - {children} -
; + + const showCameraContent = showCamera && shouldShowCameraDataForPhoto(photo); + const showTagsContent = tags.length > 0; + const showExifContent = shouldShowExifDataForPhoto(photo); return ( } contentSide={
- {renderMiniGrid(<> -
-
-
- - {titleForPhoto(photo)} - -
- -
- -
-
+ {/* Meta */} +
+
+
+ + {titleForPhoto(photo)} +
- {tags.length > 0 && - } + +
+ +
+
- {showCamera && shouldShowCameraDataForPhoto(photo) && -
- +
+ {photo.caption && +
+ {photo.caption} +
} + {(showCameraContent || showTagsContent) && +
+ {showCameraContent && + } + {showTagsContent && + } +
} +
+
+ {/* EXIF Data */} +
+ {showExifContent && + <> +
    +
  • + {photo.focalLengthFormatted} + {photo.focalLengthIn35MmFormatFormatted && + <> + {' '} + + {photo.focalLengthIn35MmFormatFormatted} + + } +
  • +
  • {photo.fNumberFormatted}
  • +
  • {photo.exposureTimeFormatted}
  • +
  • {photo.isoFormatted}
  • +
  • {photo.exposureCompensationFormatted ?? '0ev'}
  • +
{showSimulation && photo.filmSimulation && -
- -
} -
} - )} - {renderMiniGrid(<> - {shouldShowExifDataForPhoto(photo) && -
    -
  • - {photo.focalLengthFormatted} - {photo.focalLengthIn35MmFormatFormatted && - <> - {' '} - - {photo.focalLengthIn35MmFormatFormatted} - - } -
  • -
  • {photo.fNumberFormatted}
  • -
  • {photo.exposureTimeFormatted}
  • -
  • {photo.isoFormatted}
  • -
  • {photo.exposureCompensationFormatted ?? '—'}
  • -
} + } + }
{photo.takenAtNaiveFormatted}
@@ -166,7 +159,7 @@ export default function PhotoLarge({ shouldScroll={shouldScrollOnShare} />
- , false)} +
} /> ); diff --git a/src/photo/actions.tsx b/src/photo/actions.tsx index ebae1d2f..9bf682f6 100644 --- a/src/photo/actions.tsx +++ b/src/photo/actions.tsx @@ -34,51 +34,58 @@ import { } from '@/site/paths'; import { extractExifDataFromBlobPath } from './server'; import { TAG_FAVS, isTagFavs } from '@/tag'; -import { convertPhotoToPhotoDbInsert, titleForPhoto } from '.'; import { TbPhoto } from 'react-icons/tb'; import PhotoTiny from './PhotoTiny'; import { formatDate } from '@/utility/date'; +import { convertPhotoToPhotoDbInsert, titleForPhoto } from '.'; +import { safelyRunServerAdminAction } from '@/auth'; 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); - - if (updatedUrl) { photo.url = updatedUrl; } - - await sqlInsertPhoto(photo); - - revalidateAllKeysAndPaths(); - - redirect(PATH_ADMIN_PHOTOS); + const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); + + if (updatedUrl) { photo.url = updatedUrl; } + + await sqlInsertPhoto(photo); + + revalidateAllKeysAndPaths(); + + redirect(PATH_ADMIN_PHOTOS); + }); } 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( photoId: string, shouldRedirect?: boolean, ) { - const photo = await getPhoto(photoId); - if (photo) { - const { tags } = photo; - photo.tags = tags.some(tag => tag === TAG_FAVS) - ? tags.filter(tag => !isTagFavs(tag)) - : [...tags, TAG_FAVS]; - await sqlUpdatePhoto(convertPhotoToPhotoDbInsert(photo)); - revalidateAllKeysAndPaths(); - if (shouldRedirect) { - redirect(pathForPhoto(photoId)); + return safelyRunServerAdminAction(async () => { + const photo = await getPhoto(photoId); + if (photo) { + const { tags } = photo; + photo.tags = tags.some(tag => tag === TAG_FAVS) + ? tags.filter(tag => !isTagFavs(tag)) + : [...tags, TAG_FAVS]; + await sqlUpdatePhoto(convertPhotoToPhotoDbInsert(photo)); + revalidateAllKeysAndPaths(); + if (shouldRedirect) { + redirect(pathForPhoto(photoId)); + } } - } + }); } export async function deletePhotoAction( @@ -86,84 +93,98 @@ export async function deletePhotoAction( photoUrl: string, shouldRedirect?: boolean, ) { - await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl)); - revalidateAllKeysAndPaths(); - if (shouldRedirect) { - redirect(PATH_ROOT); - } + return safelyRunServerAdminAction(async () => { + await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl)); + revalidateAllKeysAndPaths(); + if (shouldRedirect) { + redirect(PATH_ROOT); + } + }); }; export async function deletePhotoFormAction(formData: FormData) { - return deletePhotoAction( - formData.get('id') as string, - formData.get('url') as string, + return safelyRunServerAdminAction(async () => + deletePhotoAction( + formData.get('id') as string, + formData.get('url') as string, + ) ); }; 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(); - revalidateAdminPaths(); + revalidatePhotosKey(); + revalidateAdminPaths(); + }); } export async function renamePhotoTagGloballyAction(formData: FormData) { - const tag = formData.get('tag') as string; - const updatedTag = formData.get('updatedTag') as string; + return safelyRunServerAdminAction(async () => { + const tag = formData.get('tag') as string; + const updatedTag = formData.get('updatedTag') as string; - if (tag && updatedTag && tag !== updatedTag) { - await sqlRenamePhotoTagGlobally(tag, updatedTag); - revalidatePhotosKey(); - revalidateTagsKey(); - redirect(PATH_ADMIN_TAGS); - } + if (tag && updatedTag && tag !== updatedTag) { + await sqlRenamePhotoTagGlobally(tag, updatedTag); + revalidatePhotosKey(); + revalidateTagsKey(); + redirect(PATH_ADMIN_TAGS); + } + }); } 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') { - redirect(PATH_ADMIN_PHOTOS); - } + if (formData.get('redirectToPhotos') === 'true') { + redirect(PATH_ADMIN_PHOTOS); + } + }); } export async function getExifDataAction( photoFormPrevious: Partial, ): Promise> { - const { url } = photoFormPrevious; - if (url) { - const { photoFormExif } = await extractExifDataFromBlobPath(url); - if (photoFormExif) { - return photoFormExif; + return safelyRunServerAdminAction(async () => { + const { url } = photoFormPrevious; + if (url) { + const { photoFormExif } = await extractExifDataFromBlobPath(url); + if (photoFormExif) { + return photoFormExif; + } } - } - return {}; + return {}; + }); } export async function syncPhotoExifDataAction(formData: FormData) { - const photoId = formData.get('id') as string; - if (photoId) { - const photo = await getPhoto(photoId); - if (photo) { - const { photoFormExif } = await extractExifDataFromBlobPath(photo.url); - if (photoFormExif) { - const photoFormDbInsert = convertFormDataToPhotoDbInsert({ - ...convertPhotoToFormData(photo), - ...photoFormExif, - }); - await sqlUpdatePhoto(photoFormDbInsert); - revalidatePhotosKey(); + return safelyRunServerAdminAction(async () => { + const photoId = formData.get('id') as string; + if (photoId) { + const photo = await getPhoto(photoId); + if (photo) { + const { photoFormExif } = await extractExifDataFromBlobPath(photo.url); + if (photoFormExif) { + const photoFormDbInsert = convertFormDataToPhotoDbInsert({ + ...convertPhotoToFormData(photo), + ...photoFormExif, + }); + await sqlUpdatePhoto(photoFormDbInsert); + revalidatePhotosKey(); + } } } - } + }); } export async function syncCacheAction() { - revalidateAllKeysAndPaths(); + return safelyRunServerAdminAction(revalidateAllKeysAndPaths); } export async function getPhotoItemsAction(query: string) { diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 96f928be..37752781 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -168,15 +168,16 @@ export default function PhotoForm({ tagOptions, readOnly, validate, + validateStringMaxLength, capitalize, hideIfEmpty, - hideBasedOnCamera, + shouldHide, loadingMessage, type, }]) => ( (!hideIfEmpty || formData[key]) && - !hideBasedOnCamera?.(formData.make) + !shouldHide?.(formData) ) && validateStringMaxLength + ? `${validateStringMaxLength} characters or less` + : undefined, + }); } if (key === 'title') { onTitleChange?.(value.trim()); diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index c1404a4a..078e36c6 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -39,10 +39,11 @@ type FormMeta = { virtual?: boolean readOnly?: boolean validate?: (value?: string) => string | undefined + validateStringMaxLength?: number capitalize?: boolean hide?: boolean hideIfEmpty?: boolean - hideBasedOnCamera?: (make?: string, mode?: string) => boolean + shouldHide?: (formData: Partial) => boolean loadingMessage?: string type?: FieldSetType selectOptions?: { value: string, label: string }[] @@ -50,10 +51,29 @@ type FormMeta = { tagOptions?: AnnotatedTag[] }; +const STRING_MAX_LENGTH_SHORT = 255; +const STRING_MAX_LENGTH_LONG = 1000; + const FORM_METADATA = ( tagOptions?: AnnotatedTag[] ): Record => ({ - 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: { label: 'tags', tagOptions, @@ -78,7 +98,7 @@ const FORM_METADATA = ( label: 'fujifilm simulation', selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS, selectOptionsDefaultLabel: 'Unknown', - hideBasedOnCamera: make => make !== MAKE_FUJIFILM, + shouldHide: ({ make }) => make !== MAKE_FUJIFILM, }, focalLength: { label: 'focal length' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, @@ -116,9 +136,12 @@ export const getFormErrors = ( export const isFormValid = (formData: Partial) => FORM_METADATA_ENTRIES().every( - ([key, { required, validate }]) => + ([key, { required, validate, validateStringMaxLength }]) => (!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 diff --git a/src/photo/index.ts b/src/photo/index.ts index 680221cc..a420b71d 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -55,6 +55,8 @@ export interface PhotoDbInsert extends PhotoExif { extension: string blurData?: string title?: string + caption?: string + semanticDescription?: string tags?: string[] locationName?: string priorityOrder?: number @@ -237,16 +239,16 @@ export const dateRangeForPhotos = ( }; const photoHasCameraData = (photo: Photo) => - photo.make && - photo.model; + Boolean(photo.make) && + Boolean(photo.model); const photoHasExifData = (photo: Photo) => - photo.focalLength || - photo.focalLengthIn35MmFormat || - photo.fNumberFormatted || - photo.isoFormatted || - photo.exposureTimeFormatted || - photo.exposureCompensationFormatted; + Boolean(photo.focalLength) || + Boolean(photo.focalLengthIn35MmFormat) || + Boolean(photo.fNumberFormatted) || + Boolean(photo.isoFormatted) || + Boolean(photo.exposureTimeFormatted) || + Boolean(photo.exposureCompensationFormatted); export const shouldShowCameraDataForPhoto = (photo: Photo) => SHOW_EXIF_DATA && photoHasCameraData(photo); diff --git a/src/services/vercel-postgres.ts b/src/services/vercel-postgres.ts index 69f592e3..97edd055 100644 --- a/src/services/vercel-postgres.ts +++ b/src/services/vercel-postgres.ts @@ -29,6 +29,8 @@ const sqlCreatePhotosTable = () => aspect_ratio REAL DEFAULT 1.5, blur_data TEXT, title VARCHAR(255), + caption TEXT, + semantic_description TEXT, tags VARCHAR(255)[], make 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 -export const sqlInsertPhoto = (photo: PhotoDbInsert) => { - return sql` +export const sqlInsertPhoto = (photo: PhotoDbInsert) => + safelyQueryPhotos(() => sql` INSERT INTO photos ( id, url, @@ -61,6 +72,8 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => { aspect_ratio, blur_data, title, + caption, + semantic_description, tags, make, model, @@ -86,6 +99,8 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => { ${photo.aspectRatio}, ${photo.blurData}, ${photo.title}, + ${photo.caption}, + ${photo.semanticDescription}, ${convertArrayToPostgresString(photo.tags)}, ${photo.make}, ${photo.model}, @@ -104,17 +119,18 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => { ${photo.takenAt}, ${photo.takenAtNaive} ) - `; -}; + `, 'sqlInsertPhoto'); export const sqlUpdatePhoto = (photo: PhotoDbInsert) => - sql` + safelyQueryPhotos(() => sql` UPDATE photos SET url=${photo.url}, extension=${photo.extension}, aspect_ratio=${photo.aspectRatio}, blur_data=${photo.blurData}, title=${photo.title}, + caption=${photo.caption}, + semantic_description=${photo.semanticDescription}, tags=${convertArrayToPostgresString(photo.tags)}, make=${photo.make}, model=${photo.model}, @@ -134,27 +150,33 @@ export const sqlUpdatePhoto = (photo: PhotoDbInsert) => taken_at_naive=${photo.takenAtNaive}, updated_at=${(new Date()).toISOString()} WHERE id=${photo.id} - `; + `, 'sqlUpdatePhoto'); export const sqlDeletePhotoTagGlobally = (tag: string) => - sql` + safelyQueryPhotos(() => sql` UPDATE photos SET tags=ARRAY_REMOVE(tags, ${tag}) WHERE ${tag}=ANY(tags) - `; + `, 'sqlDeletePhotoTagGlobally'); export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) => - sql` + safelyQueryPhotos(() => sql` UPDATE photos SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag}) WHERE ${tag}=ANY(tags) - `; + `, 'sqlRenamePhotoTagGlobally'); 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) => - sql`SELECT * FROM photos WHERE id=${id} LIMIT 1`; + safelyQueryPhotos( + () => sql`SELECT * FROM photos WHERE id=${id} LIMIT 1`, + 'sqlGetPhoto', + ); const sqlGetPhotosCount = async () => sql` SELECT COUNT(*) FROM photos @@ -287,8 +309,16 @@ const safelyQueryPhotos = async ( result = await callback(); } catch (e: any) { screenForPPR(e, undefined, 'neon postgres'); - if (/relation "photos" does not exist/i.test(e.message)) { - console.log('Creating table "photos" because it did not exist'); + if (MIGRATION_FIELDS_01.some(field => new RegExp( + `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(); result = await callback(); } else if (/endpoint is in transition/i.test(e.message)) { diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index df8b5349..585b849c 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -20,7 +20,7 @@ export default function PhotoTag({ href={pathForTag(tag)} icon={} type={type} badged={badged} diff --git a/src/tag/PhotoTags.tsx b/src/tag/PhotoTags.tsx index dc2cbe54..ef48121e 100644 --- a/src/tag/PhotoTags.tsx +++ b/src/tag/PhotoTags.tsx @@ -1,21 +1,21 @@ import PhotoTag from '@/tag/PhotoTag'; import { isTagFavs } from '.'; import FavsTag from './FavsTag'; +import { EntityLinkExternalProps } from '@/components/EntityLink'; export default function PhotoTags({ tags, - prefetch, + contrast, }: { tags: string[] - prefetch?: boolean -}) { +} & EntityLinkExternalProps) { return (
{tags.map(tag =>
{isTagFavs(tag) - ? - : } + ? + : }
)}
);