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'])}
+