Merge pull request #30 from sambecker/priority-order

Introduce global priority order
This commit is contained in:
Sam Becker 2023-12-24 15:48:50 -05:00 committed by GitHub
commit 57d77809dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 138 additions and 159 deletions

View File

@ -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

View File

@ -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}
<PhotoDetailPage
photo={photo}
photos={photos}
photosGrid={photosAfter.slice(1)}
photosGrid={photos.slice(
isPhotoFirst ? 1 : 2,
isPhotoFirst
? GRID_THUMBNAILS_TO_SHOW_MAX + 1
: GRID_THUMBNAILS_TO_SHOW_MAX + 2,
)}
/>
</>;
}

32
src/cache/index.ts vendored
View File

@ -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<typeof getPhotosNearId>
) => unstable_cache(
getPhotosNearId,
[KEY_PHOTOS],
)(...args).then(parseCachedPhotosDates);
export const getPhotosDateRangeCached =
unstable_cache(
getPhotosDateRange,

View File

@ -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<PhotoDb>`
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<PhotoDb>`
SELECT * FROM photos
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const sqlGetPhotosSortedByCreatedAt = (
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
) =>
sql<PhotoDb>`
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<PhotoDb>`
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<PhotoDb>`
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<PhotoDb>`
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<PhotoDb>`
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<PhotoDb>`
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<PhotoDb>`
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<PhotoDb>`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 <T>(callback: () => Promise<T>): Promise<T> => {
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<Photo | undefined> => {
// Check for photo id forwarding
// and convert short ids to uuids

View File

@ -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'])}
</ChecklistRow>
<ChecklistRow
title="Priority Order"
status={isPriorityOrderEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
priority order photo field affecting photo order
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
</ChecklistRow>
<ChecklistRow
title="Public API"
status={isPublicApiEnabled}

View File

@ -49,6 +49,8 @@ export const HAS_AWS_S3_STORAGE =
export const PRO_MODE_ENABLED = process.env.NEXT_PUBLIC_PRO_MODE === '1';
export const GEO_PRIVACY_ENABLED = process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const PUBLIC_API_ENABLED = process.env.NEXT_PUBLIC_PUBLIC_API === '1';
export const SHOW_REPO_LINK = process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
export const SHOW_FILM_SIMULATIONS =
@ -77,6 +79,7 @@ export const CONFIG_CHECKLIST_STATUS = {
showFilmSimulations: SHOW_FILM_SIMULATIONS,
isProModeEnabled: PRO_MODE_ENABLED,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isPublicApiEnabled: PUBLIC_API_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
gridAspectRatio: GRID_ASPECT_RATIO,