Add hidden field to photos
This commit is contained in:
parent
061d3bb03b
commit
53db663a5c
@ -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 ||
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
Untitled
|
||||
</span>}
|
||||
<span className={cc(
|
||||
'inline-flex items-center gap-2',
|
||||
photo.hidden && 'text-gray-400 dark:text-gray-500',
|
||||
)}>
|
||||
<span>{photo.title || 'Untitled'}</span>
|
||||
{photo.hidden &&
|
||||
<AiOutlineEyeInvisible
|
||||
className="translate-y-[0.25px]"
|
||||
size={16}
|
||||
/>}
|
||||
</span>
|
||||
{photo.priorityOrder !== null &&
|
||||
<span className={cc(
|
||||
'text-xs leading-none px-1.5 py-1 rounded-sm',
|
||||
|
||||
13
src/cache/index.ts
vendored
13
src/cache/index.ts
vendored
@ -5,6 +5,7 @@ import {
|
||||
getPhoto,
|
||||
getPhotos,
|
||||
getPhotosCount,
|
||||
getPhotosCountIncludingHidden,
|
||||
getUniqueTags,
|
||||
} from '@/services/postgres';
|
||||
import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo';
|
||||
@ -25,6 +26,7 @@ const getPhotosCacheTags = (options: GetPhotosOptions = {}) => {
|
||||
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),
|
||||
|
||||
@ -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<HTMLInputElement>
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
@ -34,7 +35,7 @@ export default function FieldSetWithStatus({
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
className="flex gap-2 items-center"
|
||||
className="flex gap-2 items-center select-none"
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
@ -57,11 +58,13 @@ export default function FieldSetWithStatus({
|
||||
name={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value ? 'true' : 'false'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
readOnly={readOnly || pending}
|
||||
className="w-full"
|
||||
className={cc(type === 'text' && 'w-full')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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]) &&
|
||||
<FieldSetWithStatus
|
||||
key={key}
|
||||
@ -89,6 +94,7 @@ export default function PhotoForm({
|
||||
? loadingMessage
|
||||
: undefined}
|
||||
loading={loadingMessage && !formData[key] ? true : false}
|
||||
type={checkbox ? 'checkbox' : undefined}
|
||||
/>)}
|
||||
<div className="flex gap-4">
|
||||
{type === 'edit' &&
|
||||
|
||||
@ -19,6 +19,7 @@ type FormMeta = {
|
||||
hideIfEmpty?: boolean
|
||||
hideTemporarily?: boolean
|
||||
loadingMessage?: string
|
||||
checkbox?: boolean
|
||||
};
|
||||
|
||||
const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||
@ -45,6 +46,7 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||
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',
|
||||
};
|
||||
};
|
||||
|
||||
@ -43,6 +43,7 @@ export interface PhotoDbInsert extends PhotoExif {
|
||||
tags?: string[]
|
||||
locationName?: string
|
||||
priorityOrder?: number
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
// Raw db response
|
||||
|
||||
@ -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<PhotoDb>`
|
||||
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<PhotoDb>`
|
||||
SELECT * FROM photos
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const sqlGetPhotosSortedByCreatedAt = (
|
||||
limit = PHOTO_DEFAULT_LIMIT,
|
||||
offset = 0,
|
||||
) =>
|
||||
sql<PhotoDb>`
|
||||
SELECT * FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
@ -154,6 +170,7 @@ const sqlGetPhotosSortedByPriority = (
|
||||
) =>
|
||||
sql<PhotoDb>`
|
||||
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<PhotoDb>`
|
||||
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<PhotoDb>`
|
||||
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<PhotoDb>`
|
||||
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 <T>(callback: () => Promise<T>): Promise<T> => {
|
||||
@ -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<Photo | undefined> => {
|
||||
|
||||
export const getPhotosCount = () => safelyQueryPhotos(sqlGetPhotosCount);
|
||||
|
||||
export const getPhotosCountIncludingHidden = () =>
|
||||
safelyQueryPhotos(sqlGetPhotosCountIncludingHidden);
|
||||
|
||||
export const getUniqueTags = () => safelyQueryPhotos(sqlGetUniqueTags);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user