Show camera devices, add clear cache button
This commit is contained in:
parent
1acda9610c
commit
6e68aa16c5
@ -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}
|
||||
|
||||
@ -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
80
src/cache/index.ts
vendored
@ -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),
|
||||
|
||||
@ -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(),
|
||||
|
||||
29
src/components/HeaderList.tsx
Normal file
29
src/components/HeaderList.tsx
Normal 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 && <> </>}
|
||||
{title}
|
||||
</div>,
|
||||
].concat(items)}
|
||||
classNameItem="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/photo/PhotoDevice.tsx
Normal file
32
src/photo/PhotoDevice.tsx
Normal 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}
|
||||
|
||||
</>}
|
||||
{model}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
{photo.model}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -15,3 +15,9 @@ export const capitalizeWords = (string: string) =>
|
||||
.split(' ')
|
||||
.map(capitalize)
|
||||
.join(' ');
|
||||
|
||||
export const parameterize = (string: string) =>
|
||||
string
|
||||
.trim()
|
||||
.replaceAll(' ', '-')
|
||||
.toLowerCase();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user