diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index edfb4745..2a848383 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -20,8 +20,9 @@ import { getBlobPhotoUrlsCached, getBlobUploadUrlsCached, getPhotosCached, - getPhotosCountCached, + getPhotosCountIncludingHiddenCached, } from '@/cache'; +import { AiOutlineEyeInvisible } from 'react-icons/ai'; export const runtime = 'edge'; @@ -40,8 +41,8 @@ export default async function AdminPage({ blobUploadUrls, blobPhotoUrls, ] = await Promise.all([ - getPhotosCached({ sortBy: 'createdAt', limit }), - getPhotosCountCached(), + getPhotosCached({ includeHidden: true, sortBy: 'createdAt', limit }), + getPhotosCountIncludingHiddenCached(), getBlobUploadUrlsCached(), DEBUG_PHOTO_BLOBS ? getBlobPhotoUrlsCached() : [], ]); @@ -81,10 +82,17 @@ export default async function AdminPage({ href={pathForPhoto(photo)} className="sm:w-[50%] flex items-center gap-2" > - {photo.title || - - Untitled - } + + {photo.title || 'Untitled'} + {photo.hidden && + } + {photo.priorityOrder !== null && { tag, takenAfterInclusive, takenBefore, + includeHidden, } = options; if (sortBy !== undefined) { tags.push(`sortBy-${sortBy}`); } @@ -35,6 +37,8 @@ const getPhotosCacheTags = (options: GetPhotosOptions = {}) => { if (takenBefore !== undefined) { tags.push(`takenBefore-${takenBefore.toISOString()}`); } // eslint-disable-next-line max-len if (takenAfterInclusive !== undefined) { tags.push(`takenAfterInclusive-${takenAfterInclusive.toISOString()}`); } + // eslint-disable-next-line max-len + if (includeHidden !== undefined) { tags.push(`includeHidden-${includeHidden}`); } return tags; }; @@ -68,6 +72,15 @@ export const getPhotosCountCached: typeof getPhotosCount = (...args) => } )(); +export const getPhotosCountIncludingHiddenCached: typeof getPhotosCount = + (...args) => + unstable_cache( + () => getPhotosCountIncludingHidden(...args), + [TAG_PHOTOS, TAG_PHOTOS_COUNT], { + tags: [TAG_PHOTOS, TAG_PHOTOS_COUNT], + } + )(); + export const getPhotoCached: typeof getPhoto = (...args) => unstable_cache( () => getPhoto(...args), diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index d3ea17d7..844d3ae3 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -3,6 +3,7 @@ import { LegacyRef } from 'react'; import { experimental_useFormStatus as useFormStatus } from 'react-dom'; import Spinner from './Spinner'; +import { cc } from '@/utility/css'; export default function FieldSetWithStatus({ id, @@ -26,7 +27,7 @@ export default function FieldSetWithStatus({ loading?: boolean required?: boolean readOnly?: boolean - type?: 'text' | 'password' + type?: 'text' | 'password' | 'checkbox' inputRef?: LegacyRef }) { const { pending } = useFormStatus(); @@ -34,7 +35,7 @@ export default function FieldSetWithStatus({ return (
); diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 664adae7..e5af3648 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -71,10 +71,15 @@ export default function PhotoForm({ action={type === 'create' ? createPhotoAction : updatePhotoAction} className="space-y-6 pb-12" > - {FORM_METADATA_ENTRIES.map(([ - key, - { label, note, required, readOnly, hideIfEmpty, loadingMessage }, - ]) => + {FORM_METADATA_ENTRIES.map(([key, { + label, + note, + required, + readOnly, + hideIfEmpty, + loadingMessage, + checkbox, + }]) => (!hideIfEmpty || formData[key]) && )}
{type === 'edit' && diff --git a/src/photo/form.ts b/src/photo/form.ts index f58a2968..b4ea8580 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -19,6 +19,7 @@ type FormMeta = { hideIfEmpty?: boolean hideTemporarily?: boolean loadingMessage?: string + checkbox?: boolean }; const FORM_METADATA: Record = { @@ -45,6 +46,7 @@ const FORM_METADATA: Record = { priorityOrder: { label: 'priority order' }, takenAt: { label: 'taken at' }, takenAtNaive: { label: 'taken at (naive)' }, + hidden: { label: 'hidden', checkbox: true }, }; export const FORM_METADATA_ENTRIES = @@ -146,5 +148,6 @@ export const convertFormDataToPhoto = ( priorityOrder: photoForm.priorityOrder ? parseFloat(photoForm.priorityOrder) : undefined, + hidden: photoForm.hidden === 'true', }; }; diff --git a/src/photo/index.ts b/src/photo/index.ts index 31457165..b8cfdf56 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -43,6 +43,7 @@ export interface PhotoDbInsert extends PhotoExif { tags?: string[] locationName?: string priorityOrder?: number + hidden?: boolean } // Raw db response diff --git a/src/services/postgres.ts b/src/services/postgres.ts index e63d38e7..d5bce43e 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -38,6 +38,7 @@ const sqlCreatePhotosTable = () => priority_order REAL, taken_at TIMESTAMP WITH TIME ZONE NOT NULL, taken_at_naive VARCHAR(255) NOT NULL, + hidden BOOLEAN, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ) @@ -67,6 +68,7 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => { longitude, film_simulation, priority_order, + hidden, taken_at, taken_at_naive ) @@ -91,6 +93,7 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => { ${photo.longitude}, ${photo.filmSimulation}, ${photo.priorityOrder}, + ${photo.hidden}, ${photo.takenAt}, ${photo.takenAtNaive} ) @@ -119,6 +122,7 @@ export const sqlUpdatePhoto = (photo: PhotoDbInsert) => longitude=${photo.longitude}, film_simulation=${photo.filmSimulation}, priority_order=${photo.priorityOrder || null}, + hidden=${photo.hidden}, taken_at=${photo.takenAt}, taken_at_naive=${photo.takenAtNaive}, updated_at=${(new Date()).toISOString()} @@ -134,16 +138,28 @@ const sqlGetPhotos = ( ) => 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} `; @@ -154,6 +170,7 @@ const sqlGetPhotosSortedByPriority = ( ) => sql` SELECT * FROM photos + WHERE hidden IS NOT TRUE ORDER BY priority_order ASC, taken_at DESC LIMIT ${limit} OFFSET ${offset} `; @@ -164,7 +181,9 @@ const sqlGetPhotosByTag = ( tag: string, ) => sql` - SELECT * FROM photos WHERE ${tag}=ANY(tags) + SELECT * FROM photos + WHERE ${tag}=ANY(tags) + AND hidden IS NOT TRUE ORDER BY taken_at ASC LIMIT ${limit} OFFSET ${offset} `; @@ -176,6 +195,7 @@ const sqlGetPhotosTakenAfterDateInclusive = ( sql` SELECT * FROM photos WHERE taken_at <= ${takenAt.toISOString()} + AND hidden IS NOT TRUE ORDER BY taken_at DESC LIMIT ${limit} `; @@ -187,6 +207,7 @@ const sqlGetPhotosTakenBeforeDate = ( sql` SELECT * FROM photos WHERE taken_at > ${takenAt.toISOString()} + AND hidden IS NOT TRUE ORDER BY taken_at ASC LIMIT ${limit} `; @@ -196,10 +217,16 @@ const sqlGetPhoto = (id: string) => const sqlGetPhotosCount = async () => sql` SELECT COUNT(*) FROM photos + WHERE hidden IS NOT TRUE +`.then(({ rows }) => parseInt(rows[0].count, 10)); + +const sqlGetPhotosCountIncludingHidden = async () => sql` + SELECT COUNT(*) FROM photos `.then(({ rows }) => parseInt(rows[0].count, 10)); const sqlGetUniqueTags = async () => sql` SELECT DISTINCT unnest(tags) FROM photos + WHERE hidden IS NOT TRUE `.then(({ rows }) => rows.map(row => row.unnest as string)); export type GetPhotosOptions = { @@ -209,6 +236,7 @@ export type GetPhotosOptions = { tag?: string takenBefore?: Date takenAfterInclusive?: Date + includeHidden?: boolean } const safelyQueryPhotos = async (callback: () => Promise): Promise => { @@ -249,19 +277,25 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { tag, takenBefore, takenAfterInclusive, + includeHidden, } = options; - const getPhotosSql = takenBefore - ? () => sqlGetPhotosTakenBeforeDate(takenBefore, limit) - : takenAfterInclusive - ? () => sqlGetPhotosTakenAfterDateInclusive(takenAfterInclusive, limit) - : tag - ? () => sqlGetPhotosByTag(limit, offset, tag) - : sortBy === 'createdAt' - ? () => sqlGetPhotosSortedByCreatedAt(limit, offset) - : sortBy === 'priority' - ? () => sqlGetPhotosSortedByPriority(limit, offset) - : () => sqlGetPhotos(limit, offset); + let getPhotosSql = () => sqlGetPhotos(limit, offset); + + if (includeHidden) { + getPhotosSql = () => sqlGetPhotosIncludingHidden(limit, offset); + } 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, offset, tag); + } else if (sortBy === 'createdAt') { + getPhotosSql = () => sqlGetPhotosSortedByCreatedAt(limit, offset); + } else if (sortBy === 'priority') { + getPhotosSql = () => sqlGetPhotosSortedByPriority(limit, offset); + } return safelyQueryPhotos(getPhotosSql) .then(({ rows }) => rows.map(parsePhotoFromDb)); @@ -278,4 +312,7 @@ export const getPhoto = async (id: string): Promise => { export const getPhotosCount = () => safelyQueryPhotos(sqlGetPhotosCount); +export const getPhotosCountIncludingHidden = () => + safelyQueryPhotos(sqlGetPhotosCountIncludingHidden); + export const getUniqueTags = () => safelyQueryPhotos(sqlGetUniqueTags); diff --git a/src/site/globals.css b/src/site/globals.css index e1fd9045..f0f5cf7d 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -47,6 +47,10 @@ file:active:disabled:bg-white file:hover:disabled:cursor-not-allowed } + input[type=checkbox] { + @apply + rounded-md + } button, .button { @apply cursor-pointer