Show camera devices, add clear cache button

This commit is contained in:
Sam Becker 2023-10-01 22:58:55 -05:00
parent 1acda9610c
commit 6e68aa16c5
13 changed files with 225 additions and 72 deletions

View File

@ -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={
<div className="mt-4 space-y-4">
<div className="space-y-8">
<PhotoUploadInput />
<div className="flex items-center">
<div className="flex-grow">
<PhotoUploadInput />
</div>
<form
className="hidden md:block"
action={syncCacheAction}
>
<SubmitButtonWithStatus
icon={<BiTrash />}
>
Clear Cache
</SubmitButtonWithStatus>
</form>
</div>
{blobUploadUrls.length > 0 &&
<BlobUrls
blobUrls={blobUploadUrls}

View File

@ -1,18 +1,22 @@
import {
getPhotosCached,
getPhotosCountCached,
getUniqueDevicesCached,
getUniqueTagsCached,
} from '@/cache';
import AnimateItems from '@/components/AnimateItems';
import HeaderList from '@/components/HeaderList';
import MorePhotos from '@/components/MorePhotos';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
import PhotoDevice from '@/photo/PhotoDevice';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
import { pathForGrid } from '@/site/paths';
import PhotoTag from '@/tag/PhotoTag';
import { Metadata } from 'next';
import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
export const runtime = 'edge';
@ -32,10 +36,12 @@ export default async function GridPage({
photos,
count,
tags,
devices,
] = await Promise.all([
getPhotosCached({ limit }),
getPhotosCountCached(),
getUniqueTagsCached(),
getUniqueDevicesCached(),
]);
const showMorePhotos = count > photos.length;
@ -48,11 +54,25 @@ export default async function GridPage({
{showMorePhotos &&
<MorePhotos path={pathForGrid(offset + 1)} />}
</div>}
contentSide={tags &&
<AnimateItems
items={tags.map(tag => <PhotoTag key={tag} tag={tag} />)}
staggerOnFirstLoadOnly
contentSide={<div className="sticky top-4 space-y-4">
{tags.length > 0 && <HeaderList
title='Tags'
icon={<FaTag size={12} />}
items={tags.map(tag =>
<PhotoTag key={tag} tag={tag} showIcon={false} />)}
/>}
{devices.length > 0 && <HeaderList
title="Devices"
icon={<IoMdCamera size={13} />}
items={devices.map(({ device, make, model }) =>
<PhotoDevice
key={device}
make={make}
model={model}
hideApple
/>)}
/>}
</div>}
sideHiddenOnMobile
/>
: <PhotosEmptyState />

80
src/cache/index.ts vendored
View File

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

View File

@ -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) =>
<motion.div
key={index}
className={classNameItem}
style={getInitialVariant()}
variants={{
hidden: getInitialVariant(),

View File

@ -0,0 +1,29 @@
import { cc } from '@/utility/css';
import AnimateItems from './AnimateItems';
export default function HeaderList({
title,
icon,
items,
}: {
title: string,
icon?: JSX.Element,
items: JSX.Element[]
}) {
return (
<AnimateItems
items={[
<div key="header" className={cc(
'text-gray-900 dark:text-gray-100',
'flex items-center mb-0.5',
'uppercase',
)}>
{icon}
{icon && <>&nbsp;</>}
{title}
</div>,
].concat(items)}
classNameItem="text-gray-400 dark:text-gray-500"
/>
);
}

32
src/photo/PhotoDevice.tsx Normal file
View File

@ -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 (
<div className={cc(
'inline-flex items-center self-start',
'uppercase',
)}>
{!(hideApple && make === 'Apple') &&
<>
{make === 'Apple'
? <AiFillApple
title="Apple"
className="translate-y-[-0.5px]"
size={14}
/>
: make}
&nbsp;
</>}
{model}
</div>
);
}

View File

@ -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 &&
<PhotoTags tags={tagsToShow} />}
</div>
<PhotoMakeModel photo={photo} />
<PhotoDevice make={photo.make} model={photo.model} />
</>)}
{renderMiniGrid(<>
<ul className={cc(

View File

@ -1,25 +0,0 @@
import { Photo } from '.';
import { AiFillApple } from 'react-icons/ai';
import { cc } from '@/utility/css';
export default function PhotoMakeModel({
photo,
}: {
photo: Photo
}) {
return (
<div className={cc(
'inline-flex items-center self-start',
'uppercase',
)}>
{photo.make === 'Apple'
? <AiFillApple
title="Apple"
className="translate-y-[-0.5px]"
/>
: photo.make}
&nbsp;
{photo.model}
</div>
);
}

View File

@ -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();
}

View File

@ -188,6 +188,17 @@ const sqlGetPhotosByTag = (
LIMIT ${limit} OFFSET ${offset}
`;
const sqlGetPhotosByDevice = async (
limit = PHOTO_DEFAULT_LIMIT,
make: string,
model: string,
) => sql<PhotoDb>`
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 <T>(callback: () => Promise<T>): Promise<T> => {
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);

View File

@ -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}`;

View File

@ -5,8 +5,10 @@ import { cc } from '@/utility/css';
export default function PhotoTag({
tag,
showIcon = true,
}: {
tag: string
showIcon?: boolean
}) {
return (
<Link
@ -14,13 +16,14 @@ export default function PhotoTag({
href={pathForTag(tag)}
className="flex items-center gap-x-1.5 self-start"
>
<FaTag
size={11}
className={cc(
'flex-shrink-0',
'text-gray-700 dark:text-gray-300 translate-y-[0.5px]',
)}
/>
{showIcon &&
<FaTag
size={11}
className={cc(
'flex-shrink-0',
'text-gray-700 dark:text-gray-300 translate-y-[0.5px]',
)}
/>}
<span className="uppercase">
{tag.replaceAll('-', ' ')}
</span>

View File

@ -15,3 +15,9 @@ export const capitalizeWords = (string: string) =>
.split(' ')
.map(capitalize)
.join(' ');
export const parameterize = (string: string) =>
string
.trim()
.replaceAll(' ', '-')
.toLowerCase();