From 6e68aa16c532ed1c05e665996baab893a3be22ad Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Oct 2023 22:58:55 -0500 Subject: [PATCH] Show camera devices, add clear cache button --- src/app/(auth-state)/admin/photos/page.tsx | 18 ++++- src/app/(static)/grid/page.tsx | 30 ++++++-- src/cache/index.ts | 80 ++++++++++++++++------ src/components/AnimateItems.tsx | 3 + src/components/HeaderList.tsx | 29 ++++++++ src/photo/PhotoDevice.tsx | 32 +++++++++ src/photo/PhotoLarge.tsx | 4 +- src/photo/PhotoMakeModel.tsx | 25 ------- src/photo/actions.ts | 8 ++- src/services/postgres.ts | 33 +++++++-- src/site/paths.ts | 12 +++- src/tag/PhotoTag.tsx | 17 +++-- src/utility/string.ts | 6 ++ 13 files changed, 225 insertions(+), 72 deletions(-) create mode 100644 src/components/HeaderList.tsx create mode 100644 src/photo/PhotoDevice.tsx delete mode 100644 src/photo/PhotoMakeModel.tsx diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index d0835eb6..85914ac8 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -9,6 +9,7 @@ import SiteGrid from '@/components/SiteGrid'; import { deletePhotoAction, deleteBlobPhotoAction, + syncCacheAction, } from '@/photo/actions'; import { FaRegEdit } from 'react-icons/fa'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; @@ -27,6 +28,7 @@ import { getPhotosCountIncludingHiddenCached, } from '@/cache'; import { AiOutlineEyeInvisible } from 'react-icons/ai'; +import { BiTrash } from 'react-icons/bi'; export const runtime = 'edge'; @@ -58,7 +60,21 @@ export default async function AdminPage({ contentMain={
- +
+
+ +
+
+ } + > + Clear Cache + +
+
{blobUploadUrls.length > 0 && photos.length; @@ -48,11 +54,25 @@ export default async function GridPage({ {showMorePhotos && }
} - contentSide={tags && - )} - staggerOnFirstLoadOnly + contentSide={
+ {tags.length > 0 && } + items={tags.map(tag => + )} />} + {devices.length > 0 && } + items={devices.map(({ device, make, model }) => + )} + />} +
} sideHiddenOnMobile /> : diff --git a/src/cache/index.ts b/src/cache/index.ts index b7d79606..f0949630 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -5,6 +5,7 @@ import { getPhotos, getPhotosCount, getPhotosCountIncludingHidden, + getUniqueDevices, getUniqueTags, } from '@/services/postgres'; import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo'; @@ -14,31 +15,45 @@ import { AuthSession } from 'next-auth'; const TAG_PHOTOS = 'photos'; const TAG_PHOTOS_COUNT = 'photos-count'; const TAG_TAGS = 'tags'; +const TAG_DEVICES = 'devices'; const TAG_BLOB = 'blob'; -const getPhotosCacheTags = (options: GetPhotosOptions = {}) => { - const tags = []; - - const { - sortBy, - limit, - offset, - tag, - takenAfterInclusive, - takenBefore, - includeHidden, - } = options; +// eslint-disable-next-line max-len +const getPhotosCacheTagForKey = ( + options: GetPhotosOptions, + key: keyof GetPhotosOptions, +): string | null => { + switch (key) { + // Primitive keys + case 'sortBy': + case 'limit': + case 'offset': + case 'tag': + case 'includeHidden': { + const value = options[key]; + return value ? `${key}-${value}` : null; + } + // Date keys + case 'takenBefore': + case 'takenAfterInclusive': { + const value = options[key]; + return value ? `${key}-${value.toISOString()}` : null; + } + // Complex keys + case 'device': { + const value = options[key]; + return value ? `${key}-${value.make}-${value.model}` : null; + } + } +}; - if (sortBy !== undefined) { tags.push(`sortBy-${sortBy}`); } - if (limit !== undefined) { tags.push(`limit-${limit}`); } - if (offset !== undefined) { tags.push(`offset-${offset}`); } - if (tag !== undefined) { tags.push(`tag-${tag}`); } - // eslint-disable-next-line max-len - 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}`); } +const getPhotosCacheTags = (options: GetPhotosOptions = {}) => { + const tags: string[] = []; + + Object.keys(options).forEach(key => { + const tag = getPhotosCacheTagForKey(options, key as keyof GetPhotosOptions); + if (tag) { tags.push(tag); } + }); return tags; }; @@ -48,6 +63,12 @@ const getPhotoCacheTag = (photoId: string) => `photo-${photoId}`; export const revalidatePhotosTag = () => revalidateTag(TAG_PHOTOS); +export const revalidateTagsTag = () => + revalidateTag(TAG_TAGS); + +export const revalidateDevicesTag = () => + revalidateTag(TAG_DEVICES); + export const revalidateBlobTag = () => revalidateTag(TAG_BLOB); @@ -56,6 +77,13 @@ export const revalidatePhotosAndBlobTag = () => { revalidateTag(TAG_BLOB); }; +export const revalidateAllTags = () => { + revalidatePhotosTag(); + revalidateTagsTag(); + revalidateDevicesTag(); + revalidateBlobTag(); +}; + export const getPhotosCached: typeof getPhotos = (...args) => unstable_cache( () => getPhotos(...args), @@ -97,6 +125,14 @@ export const getUniqueTagsCached: typeof getUniqueTags = (...args) => } )(); +export const getUniqueDevicesCached: typeof getUniqueDevices = (...args) => + unstable_cache( + () => getUniqueDevices(...args), + [TAG_PHOTOS, TAG_DEVICES], { + tags: [TAG_PHOTOS, TAG_DEVICES], + } + )(); + export const getBlobUploadUrlsCached: typeof getBlobUploadUrls = (...args) => unstable_cache( () => getBlobUploadUrls(...args), diff --git a/src/components/AnimateItems.tsx b/src/components/AnimateItems.tsx index 6bc5bb4b..98d5c6ed 100644 --- a/src/components/AnimateItems.tsx +++ b/src/components/AnimateItems.tsx @@ -16,6 +16,7 @@ export interface AnimationConfig { interface Props extends AnimationConfig { className?: string + classNameItem?: string items: JSX.Element[] animateFromAppState?: boolean animateOnFirstLoadOnly?: boolean @@ -24,6 +25,7 @@ interface Props extends AnimationConfig { function AnimateItems({ className, + classNameItem, items, type = 'scale', duration = 0.6, @@ -96,6 +98,7 @@ function AnimateItems({ {items.map((item, index) => + {icon} + {icon && <> } + {title} +
, + ].concat(items)} + classNameItem="text-gray-400 dark:text-gray-500" + /> + ); +} diff --git a/src/photo/PhotoDevice.tsx b/src/photo/PhotoDevice.tsx new file mode 100644 index 00000000..78539abc --- /dev/null +++ b/src/photo/PhotoDevice.tsx @@ -0,0 +1,32 @@ +import { AiFillApple } from 'react-icons/ai'; +import { cc } from '@/utility/css'; + +export default function PhotoDevice({ + make, + model, + hideApple, +}: { + make?: string + model?: string + hideApple?: boolean +}) { + return ( +
+ {!(hideApple && make === 'Apple') && + <> + {make === 'Apple' + ? + : make} +   + } + {model} +
+ ); +} diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 5e11a715..a9e6c67c 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import { pathForPhoto, pathForPhotoShare } from '@/site/paths'; import PhotoTags from '@/tag/PhotoTags'; import ShareButton from '@/components/ShareButton'; -import PhotoMakeModel from './PhotoMakeModel'; +import PhotoDevice from './PhotoDevice'; export default function PhotoLarge({ photo, @@ -64,7 +64,7 @@ export default function PhotoLarge({ {tagsToShow.length > 0 && } - + )} {renderMiniGrid(<>
    - {photo.make === 'Apple' - ? - : photo.make} -   - {photo.model} - - ); -} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index b5aeb33a..f27e7989 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -12,8 +12,8 @@ import { deleteBlobPhoto, } from '@/services/blob'; import { + revalidateAllTags, revalidateBlobTag, - revalidatePhotosAndBlobTag, revalidatePhotosTag, } from '@/cache'; import { IS_PRO_MODE } from '@/site/config'; @@ -38,7 +38,7 @@ export async function createPhotoAction(formData: FormData) { await sqlInsertPhoto(photo); - revalidatePhotosAndBlobTag(); + revalidateAllTags(); redirect('/admin/photos'); } @@ -67,3 +67,7 @@ export async function deleteBlobPhotoAction(formData: FormData) { revalidateBlobTag(); }; + +export async function syncCacheAction() { + revalidateAllTags(); +} diff --git a/src/services/postgres.ts b/src/services/postgres.ts index d5bce43e..88a640c3 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -188,6 +188,17 @@ const sqlGetPhotosByTag = ( LIMIT ${limit} OFFSET ${offset} `; +const sqlGetPhotosByDevice = async ( + limit = PHOTO_DEFAULT_LIMIT, + make: string, + model: string, +) => sql` + SELECT * FROM photos + WHERE make=${make} AND model=${model} + ORDER BY taken_at DESC + LIMIT ${limit} +`; + const sqlGetPhotosTakenAfterDateInclusive = ( takenAt: Date, limit?: number, @@ -225,15 +236,24 @@ const sqlGetPhotosCountIncludingHidden = async () => sql` `.then(({ rows }) => parseInt(rows[0].count, 10)); const sqlGetUniqueTags = async () => sql` - SELECT DISTINCT unnest(tags) FROM photos + SELECT DISTINCT unnest(tags) as tag FROM photos WHERE hidden IS NOT TRUE -`.then(({ rows }) => rows.map(row => row.unnest as string)); + ORDER BY tag ASC +`.then(({ rows }) => rows.map(row => row.tag as string)); + +const sqlGetUniqueDevices = async () => sql` + SELECT DISTINCT make||' '||model as device, make, model FROM photos + WHERE hidden IS NOT TRUE + ORDER BY device ASC +`.then(({ rows }) => + rows as { device: string, make: string, model: string }[]); export type GetPhotosOptions = { sortBy?: 'createdAt' | 'takenAt' | 'priority' limit?: number offset?: number tag?: string + device?: { make: string, model: string } takenBefore?: Date takenAfterInclusive?: Date includeHidden?: boolean @@ -246,9 +266,7 @@ const safelyQueryPhotos = async (callback: () => Promise): Promise => { result = await callback(); } catch (e: any) { if (/relation "photos" does not exist/i.test(e.message)) { - console.log( - 'Creating table "photos" because it did not exist', - ); + console.log('Creating table "photos" because it did not exist'); await sqlCreatePhotosTable(); result = await callback(); } else if (/endpoint is in transition/i.test(e.message)) { @@ -275,6 +293,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { limit, offset, tag, + device, takenBefore, takenAfterInclusive, includeHidden, @@ -291,6 +310,8 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { getPhotosSql = () => sqlGetPhotosTakenAfterDateInclusive(takenAfterInclusive, limit); } else if (tag) { getPhotosSql = () => sqlGetPhotosByTag(limit, offset, tag); + } else if (device) { + getPhotosSql = () => sqlGetPhotosByDevice(limit, device.make, device.model); } else if (sortBy === 'createdAt') { getPhotosSql = () => sqlGetPhotosSortedByCreatedAt(limit, offset); } else if (sortBy === 'priority') { @@ -316,3 +337,5 @@ export const getPhotosCountIncludingHidden = () => safelyQueryPhotos(sqlGetPhotosCountIncludingHidden); export const getUniqueTags = () => safelyQueryPhotos(sqlGetUniqueTags); + +export const getUniqueDevices = () => safelyQueryPhotos(sqlGetUniqueDevices); diff --git a/src/site/paths.ts b/src/site/paths.ts index 8dc636fd..911c5f83 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -1,9 +1,11 @@ import { Photo } from '@/photo'; import { BASE_URL } from './config'; +import { parameterize } from '@/utility/string'; // Prefixes -const PREFIX_PHOTO = '/p'; -const PREFIX_TAG = '/t'; +const PREFIX_PHOTO = '/p'; +const PREFIX_TAG = '/t'; +const PREFIX_DEVICE = '/d'; // Modifiers const SHARE = 'share'; @@ -54,7 +56,11 @@ export const pathForPhotoShare = (photo: PhotoOrPhotoId, tag?: string) => export const pathForPhotoEdit = (photo: PhotoOrPhotoId) => `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`; -export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`; +export const pathForTag = (tag: string) => + `${PREFIX_TAG}/${tag}`; + +export const pathForDevice = (make?: string, model?: string) => + `${PREFIX_DEVICE}/${parameterize(`${make}-${model}`)}`; export const pathForTagShare = (tag: string) => `${pathForTag(tag)}/${SHARE}`; diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index b640fd4a..87bc5c68 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -5,8 +5,10 @@ import { cc } from '@/utility/css'; export default function PhotoTag({ tag, + showIcon = true, }: { tag: string + showIcon?: boolean }) { return ( - + {showIcon && + } {tag.replaceAll('-', ' ')} diff --git a/src/utility/string.ts b/src/utility/string.ts index ed8e8ab3..73590a36 100644 --- a/src/utility/string.ts +++ b/src/utility/string.ts @@ -15,3 +15,9 @@ export const capitalizeWords = (string: string) => .split(' ') .map(capitalize) .join(' '); + +export const parameterize = (string: string) => + string + .trim() + .replaceAll(' ', '-') + .toLowerCase();