diff --git a/README.md b/README.md index 64007c83..86a59c1f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Installation - `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage - `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data +- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order - `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` - `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo - `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar diff --git a/src/app/(static)/p/[photoId]/layout.tsx b/src/app/(static)/p/[photoId]/layout.tsx index 1664732b..19a26fc9 100644 --- a/src/app/(static)/p/[photoId]/layout.tsx +++ b/src/app/(static)/p/[photoId]/layout.tsx @@ -11,7 +11,7 @@ import { absolutePathForPhotoImage, } from '@/site/paths'; import PhotoDetailPage from '@/photo/PhotoDetailPage'; -import { getPhotoCached, getPhotosCached } from '@/cache'; +import { getPhotoCached, getPhotosNearIdCached } from '@/cache'; interface PhotoProps { params: { photoId: string } @@ -50,31 +50,29 @@ export async function generateMetadata({ export default async function PhotoPage({ params: { photoId }, children, -}: - PhotoProps & { children: React.ReactNode }) { - const photo = await getPhotoCached(photoId); +}: PhotoProps & { children: React.ReactNode }) { + const photos = await getPhotosNearIdCached( + photoId, + GRID_THUMBNAILS_TO_SHOW_MAX + 2, + ); + + const photo = photos.find(p => p.id === photoId); if (!photo) { redirect(PATH_ROOT); } - - const [ - photosBefore, - photosAfter, - ] = await Promise.all([ - getPhotosCached({ takenBefore: photo.takenAt, limit: 1 }), - getPhotosCached({ - takenAfterInclusive: photo.takenAt, - limit: GRID_THUMBNAILS_TO_SHOW_MAX + 1, - }), - ]); - - const photos = photosBefore.concat(photosAfter); + + const isPhotoFirst = photos.findIndex(p => p.id === photoId) === 0; return <> {children} ; } diff --git a/src/cache/index.ts b/src/cache/index.ts index 0bdd40eb..db0f2387 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -21,6 +21,7 @@ import { getPhotosFilmSimulationDateRange, getPhotosFilmSimulationCount, getPhotosDateRange, + getPhotosNearId, } from '@/services/vercel-postgres'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob'; @@ -45,26 +46,20 @@ const getPhotosCacheKeyForOption = ( option: keyof GetPhotosOptions, ): string | null => { switch (option) { - // Primitive keys - case 'sortBy': - case 'limit': - case 'tag': - case 'simulation': - case 'includeHidden': { - const value = options[option]; - return value ? `${option}-${value}` : null; - } - // Date keys - case 'takenBefore': - case 'takenAfterInclusive': { - const value = options[option]; - return value ? `${option}-${value.toISOString()}` : null; - } // Complex keys case 'camera': { const value = options[option]; return value ? `${option}-${createCameraKey(value)}` : null; } + case 'takenBefore': + case 'takenAfterInclusive': { + const value = options[option]; + return value ? `${option}-${value.toISOString()}` : null; + } + // Primitive keys + default: + const value = options[option]; + return value !== undefined ? `${option}-${value}` : null; } }; @@ -119,6 +114,13 @@ export const getPhotosCached = ( [KEY_PHOTOS, ...getPhotosCacheKeys(...args)], )(...args).then(parseCachedPhotosDates); +export const getPhotosNearIdCached = ( + ...args: Parameters +) => unstable_cache( + getPhotosNearId, + [KEY_PHOTOS], +)(...args).then(parseCachedPhotosDates); + export const getPhotosDateRangeCached = unstable_cache( getPhotosDateRange, diff --git a/src/services/vercel-postgres.ts b/src/services/vercel-postgres.ts index 80fd6f81..d3af54aa 100644 --- a/src/services/vercel-postgres.ts +++ b/src/services/vercel-postgres.ts @@ -1,4 +1,4 @@ -import { sql } from '@vercel/postgres'; +import { db, sql } from '@vercel/postgres'; import { PhotoDb, PhotoDbInsert, @@ -11,6 +11,7 @@ import { Camera, Cameras, createCameraKey } from '@/camera'; import { parameterize } from '@/utility/string'; import { Tags } from '@/tag'; import { FilmSimulation, FilmSimulations } from '@/simulation'; +import { PRIORITY_ORDER_ENABLED } from '@/site/config'; const PHOTO_DEFAULT_LIMIT = 100; @@ -151,109 +152,6 @@ export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) => export const sqlDeletePhoto = (id: string) => sql`DELETE FROM photos WHERE id=${id}`; -const sqlGetPhotos = ( - limit = PHOTO_DEFAULT_LIMIT, - offset = 0, -) => - sql` - SELECT * FROM photos - WHERE hidden IS NOT TRUE - ORDER BY taken_at DESC - LIMIT ${limit} OFFSET ${offset} - `; - -const sqlGetPhotosIncludingHidden = ( - limit = PHOTO_DEFAULT_LIMIT, - offset = 0, -) => - sql` - SELECT * FROM photos - ORDER BY created_at DESC - LIMIT ${limit} OFFSET ${offset} - `; - -const sqlGetPhotosSortedByCreatedAt = ( - limit = PHOTO_DEFAULT_LIMIT, - offset = 0, -) => - sql` - SELECT * FROM photos - WHERE hidden IS NOT TRUE - ORDER BY created_at DESC - LIMIT ${limit} OFFSET ${offset} - `; - -const sqlGetPhotosSortedByPriority = ( - limit = PHOTO_DEFAULT_LIMIT, - offset = 0, -) => - sql` - SELECT * FROM photos - WHERE hidden IS NOT TRUE - ORDER BY priority_order ASC, taken_at DESC - LIMIT ${limit} OFFSET ${offset} - `; - -const sqlGetPhotosByTag = ( - limit = PHOTO_DEFAULT_LIMIT, - tag: string, -) => - sql` - SELECT * FROM photos - WHERE ${tag}=ANY(tags) - AND hidden IS NOT TRUE - ORDER BY taken_at DESC - LIMIT ${limit} - `; - -const sqlGetPhotosByCamera = async ( - limit = PHOTO_DEFAULT_LIMIT, - make: string, - model: string, -) => sql` - SELECT * FROM photos - WHERE - LOWER(make)=${parameterize(make)} AND - LOWER(REPLACE(model, ' ', '-'))=${parameterize(model)} - ORDER BY taken_at DESC - LIMIT ${limit} -`; - -const sqlGetPhotosBySimulation = async ( - limit = PHOTO_DEFAULT_LIMIT, - simulation: FilmSimulation, -) => sql` - SELECT * FROM photos - WHERE film_simulation=${simulation} - AND hidden IS NOT TRUE - ORDER BY taken_at DESC - LIMIT ${limit} -`; - -const sqlGetPhotosTakenAfterDateInclusive = ( - takenAt: Date, - limit?: number, -) => - sql` - SELECT * FROM photos - WHERE taken_at <= ${takenAt.toISOString()} - AND hidden IS NOT TRUE - ORDER BY taken_at DESC - LIMIT ${limit} - `; - -const sqlGetPhotosTakenBeforeDate = ( - takenAt: Date, - limit?: number, -) => - sql` - SELECT * FROM photos - WHERE taken_at > ${takenAt.toISOString()} - AND hidden IS NOT TRUE - ORDER BY taken_at ASC - LIMIT ${limit} - `; - const sqlGetPhoto = (id: string) => sql`SELECT * FROM photos WHERE id=${id} LIMIT 1`; @@ -367,6 +265,7 @@ const sqlGetUniqueFilmSimulations = async () => sql` export type GetPhotosOptions = { sortBy?: 'createdAt' | 'takenAt' | 'priority' limit?: number + offset?: number tag?: string camera?: Camera simulation?: FilmSimulation @@ -403,11 +302,11 @@ const safelyQueryPhotos = async (callback: () => Promise): Promise => { return result; }; -// PHOTOS export const getPhotos = async (options: GetPhotosOptions = {}) => { const { - sortBy = 'takenAt', - limit, + sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt', + limit = PHOTO_DEFAULT_LIMIT, + offset = 0, tag, camera, simulation, @@ -416,30 +315,95 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { includeHidden, } = options; - let getPhotosSql = () => sqlGetPhotos(limit); + let sql = ['SELECT * FROM photos']; + let values = [] as (string | number)[]; + let valueIndex = 1; - if (includeHidden) { - getPhotosSql = () => sqlGetPhotosIncludingHidden(limit); - } else if (takenBefore) { - getPhotosSql = () => sqlGetPhotosTakenBeforeDate(takenBefore, limit); - } else if (takenAfterInclusive) { - // eslint-disable-next-line max-len - getPhotosSql = () => sqlGetPhotosTakenAfterDateInclusive(takenAfterInclusive, limit); - } else if (tag) { - getPhotosSql = () => sqlGetPhotosByTag(limit, tag); - } else if (camera) { - getPhotosSql = () => sqlGetPhotosByCamera(limit, camera.make, camera.model); - } else if (simulation) { - getPhotosSql = () => sqlGetPhotosBySimulation(limit, simulation); - } else if (sortBy === 'createdAt') { - getPhotosSql = () => sqlGetPhotosSortedByCreatedAt(limit); - } else if (sortBy === 'priority') { - getPhotosSql = () => sqlGetPhotosSortedByPriority(limit); + // WHERE + let wheres = [] as string[]; + if (!includeHidden) { + wheres.push('hidden IS NOT TRUE'); + } + if (takenBefore) { + wheres.push(`taken_at > $${valueIndex++}`); + values.push(takenBefore.toISOString()); + } + if (takenAfterInclusive) { + wheres.push(`taken_at <= $${valueIndex++}`); + values.push(takenAfterInclusive.toISOString()); + } + if (tag) { + wheres.push(`$${valueIndex++}=ANY(tags)`); + values.push(tag); + } + if (camera) { + wheres.push(`LOWER(make)=$${valueIndex++}`); + wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valueIndex++}`); + values.push(parameterize(camera.make)); + values.push(parameterize(camera.model)); + } + if (simulation) { + wheres.push(`film_simulation=$${valueIndex++}`); + values.push(simulation); + } + if (wheres.length > 0) { + sql.push(`WHERE ${wheres.join(' AND ')}`); } - return safelyQueryPhotos(getPhotosSql) + // ORDER BY + switch (sortBy) { + case 'createdAt': + sql.push('ORDER BY created_at DESC'); + break; + case 'takenAt': + sql.push('ORDER BY taken_at DESC'); + break; + case 'priority': + sql.push('ORDER BY priority_order ASC, taken_at DESC'); + break; + } + + // LIMIT + OFFSET + sql.push(`LIMIT $${valueIndex++} OFFSET $${valueIndex++}`); + values.push(limit, offset); + + return safelyQueryPhotos(async () => { + const client = await db.connect(); + return client.query(sql.join(' '), values); + }) .then(({ rows }) => rows.map(parsePhotoFromDb)); }; + +export const getPhotosNearId = async ( + id: string, + limit: number, +) => { + const orderBy = PRIORITY_ORDER_ENABLED + ? 'ORDER BY priority_order ASC, taken_at DESC' + : 'ORDER BY taken_at DESC'; + + return safelyQueryPhotos(async () => { + const client = await db.connect(); + return client.query( + ` + WITH twi AS ( + SELECT *, row_number() + OVER (${orderBy}) as row_number + FROM photos + WHERE hidden IS NOT TRUE + ), + current AS (SELECT row_number FROM twi WHERE id = $1) + SELECT twi.* + FROM twi, current + WHERE twi.row_number >= current.row_number - 1 + LIMIT $2 + `, + [id, limit] + ); + }) + .then(({ rows }) => rows.map(parsePhotoFromDb)); +}; + export const getPhoto = async (id: string): Promise => { // Check for photo id forwarding // and convert short ids to uuids diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 11c22341..9d571f37 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -33,6 +33,7 @@ export default function SiteChecklistClient({ showFilmSimulations, isProModeEnabled, isGeoPrivacyEnabled, + isPriorityOrderEnabled, isPublicApiEnabled, isOgTextBottomAligned, showRefreshButton, @@ -256,6 +257,16 @@ export default function SiteChecklistClient({ collection/display of location-based data {renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])} + + Set environment variable to {'"1"'} to prevent + priority order photo field affecting photo order + {renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])} +