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={
-
+
{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();