From c9599120d290c417c7b711858a8debbcead4b821 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 5 Oct 2023 22:01:23 -0500 Subject: [PATCH 01/12] Add admin sub-nav --- src/admin/AdminNav.tsx | 40 +++++ src/app/(auth-state)/admin/layout.tsx | 43 ++++++ src/app/(auth-state)/admin/photos/page.tsx | 168 ++++++++++----------- src/components/FooterAuth.tsx | 2 +- src/components/FooterStatic.tsx | 2 +- src/components/HeaderList.tsx | 2 +- src/components/ShareButton.tsx | 2 +- src/components/StatusIcon.tsx | 2 +- src/photo/PhotoHeader.tsx | 4 +- src/services/blob.ts | 4 +- src/site/globals.css | 8 + src/site/paths.ts | 9 +- 12 files changed, 188 insertions(+), 98 deletions(-) create mode 100644 src/admin/AdminNav.tsx create mode 100644 src/app/(auth-state)/admin/layout.tsx diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx new file mode 100644 index 00000000..8282a8e7 --- /dev/null +++ b/src/admin/AdminNav.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { cc } from '@/utility/css'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +export default function AdminNav({ + items, +}: { + items: { + label: string, + href: string, + count: number, + }[] +}) { + const pathname = usePathname(); + + return ( +
+
+ {items.map(({ label, href, count }) => + + {label} + ({count}) + )} +
+
+ ); +} diff --git a/src/app/(auth-state)/admin/layout.tsx b/src/app/(auth-state)/admin/layout.tsx new file mode 100644 index 00000000..ef916ea5 --- /dev/null +++ b/src/app/(auth-state)/admin/layout.tsx @@ -0,0 +1,43 @@ +import AdminNav from '@/admin/AdminNav'; +import { + getPhotosCountIncludingHiddenCached, + getUniqueTagsCached, +} from '@/cache'; +import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const [ + photosCount, + tagsCount, + ] = await Promise.all([ + getPhotosCountIncludingHiddenCached(), + getUniqueTagsCached().then(tags => tags.length), + ]); + + const navItemPhotos = { + label: 'Photos', + href: PATH_ADMIN_PHOTOS, + count: photosCount, + }; + + const navItemTags = { + label: 'Tags', + href: PATH_ADMIN_TAGS, + count: tagsCount, + }; + + const navItems = tagsCount > 0 + ? [navItemPhotos, navItemTags] + : [navItemPhotos]; + + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index c2abb6e8..3555ec3e 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -58,93 +58,91 @@ export default async function AdminPage({ searchParams }: PaginationParams) { return ( -
-
-
- -
-
+
+
+ +
+ + } > - } - > - Clear Cache - - -
- {blobUploadUrls.length > 0 && - } - {blobPhotoUrls.length > 0 && - } -
- - {photos.map(photo => - - -
- - - {photo.title || 'Untitled'} - {photo.hidden && - } - - {photo.priorityOrder !== null && - - {photo.priorityOrder} - } - -
- {photo.takenAtNaive} -
-
- - + +
+ {blobUploadUrls.length > 0 && + } + {blobPhotoUrls.length > 0 && + } +
+ + {photos.map(photo => + + +
+ - - - - - )} - - {showMorePhotos && - } -
+ + {photo.title || 'Untitled'} + {photo.hidden && + } + + {photo.priorityOrder !== null && + + {photo.priorityOrder} + } + +
+ {photo.takenAtNaive} +
+
+ + + + + + + )} + + {showMorePhotos && + }
} /> diff --git a/src/components/FooterAuth.tsx b/src/components/FooterAuth.tsx index 9aed1910..17f7e585 100644 --- a/src/components/FooterAuth.tsx +++ b/src/components/FooterAuth.tsx @@ -25,7 +25,7 @@ export default function FooterAuth() { contentMain={
{hasState diff --git a/src/components/FooterStatic.tsx b/src/components/FooterStatic.tsx index e4881d8d..e28ee553 100644 --- a/src/components/FooterStatic.tsx +++ b/src/components/FooterStatic.tsx @@ -18,7 +18,7 @@ export default function FooterStatic({ contentMain={
, ].concat(items)} - classNameItem="text-gray-400 dark:text-gray-500" + classNameItem="text-dim" /> ); } diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 08ec476a..a3d4388a 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -16,7 +16,7 @@ export default function ShareButton({ , prefetch, shouldScroll, diff --git a/src/components/StatusIcon.tsx b/src/components/StatusIcon.tsx index ffd5579b..46dc94de 100644 --- a/src/components/StatusIcon.tsx +++ b/src/components/StatusIcon.tsx @@ -27,7 +27,7 @@ export default function StatusIcon({ case 'optional': return ; } }; diff --git a/src/photo/PhotoHeader.tsx b/src/photo/PhotoHeader.tsx index 30c0d53a..d725451b 100644 --- a/src/photo/PhotoHeader.tsx +++ b/src/photo/PhotoHeader.tsx @@ -35,7 +35,7 @@ export default function PhotoHeader({ {entity} {selectedPhotoIndex !== undefined @@ -48,7 +48,7 @@ export default function PhotoHeader({ {start === end ? start diff --git a/src/services/blob.ts b/src/services/blob.ts index 05a2e488..a34d83a0 100644 --- a/src/services/blob.ts +++ b/src/services/blob.ts @@ -1,4 +1,4 @@ -import { PATH_ADMIN_UPLOAD_BLOB_HANDLER } from '@/site/paths'; +import { PATH_ADMIN_UPLOAD_BLOB } from '@/site/paths'; import { del, list, put } from '@vercel/blob'; import { upload } from '@vercel/blob/client'; @@ -41,7 +41,7 @@ export const uploadPhotoFromClient = async ( file, { access: 'public', - handleUploadUrl: PATH_ADMIN_UPLOAD_BLOB_HANDLER, + handleUploadUrl: PATH_ADMIN_UPLOAD_BLOB, }, ); diff --git a/src/site/globals.css b/src/site/globals.css index f0f5cf7d..89efa7a3 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -3,12 +3,14 @@ @tailwind utilities; @layer base { + /* Core */ body { @apply font-mono text-sm md:text-base bg-white dark:bg-black text-gray-900 dark:text-gray-100 } + /* Forms */ label { @apply font-sans font-medium block uppercase text-xs @@ -70,9 +72,15 @@ disabled:bg-transparent dark:disabled:bg-transparent disabled:border-gray-100 dark:disabled:border-gray-900 } + /* Toasts */ .toaster [data-sonner-toast] { @apply font-mono !border-gray-200 dark:!border-gray-800 } + /* Common Utilities */ + .text-dim { + @apply + text-gray-400 dark:text-gray-500 + } } diff --git a/src/site/paths.ts b/src/site/paths.ts index e8529434..87480fe7 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -19,10 +19,11 @@ export const PATH_SIGN_IN = '/sign-in'; export const PATH_OG = '/og'; export const PATH_CHECKLIST = '/checklist'; -// Extended paths -export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; -export const PATH_ADMIN_UPLOAD = `${PATH_ADMIN}/uploads`; -export const PATH_ADMIN_UPLOAD_BLOB_HANDLER = `${PATH_ADMIN_UPLOAD}/blob`; +// Admin paths +export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; +export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; +export const PATH_ADMIN_UPLOAD = `${PATH_ADMIN}/uploads`; +export const PATH_ADMIN_UPLOAD_BLOB = `${PATH_ADMIN_UPLOAD}/blob`; // Modifiers const SHARE = 'share'; From 35af0057c29276975a2ffa6db2ab9ef0a8e87607 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 5 Oct 2023 22:04:26 -0500 Subject: [PATCH 02/12] Bold selected text in admin nav --- src/admin/AdminNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 8282a8e7..be8c544d 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -28,7 +28,7 @@ export default function AdminNav({ href={href} className={cc( 'flex gap-0.5', - !pathname.startsWith(href) && 'text-dim', + pathname.startsWith(href) ? 'font-bold' : 'text-dim', )} > {label} From 74bc870b3dc3819f3c73f6dcebe37790fcde9aac Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 5 Oct 2023 23:06:12 -0500 Subject: [PATCH 03/12] Add admin tags page with global delete function --- src/admin/AdminGrid.tsx | 26 +++++++++ src/admin/AdminNav.tsx | 45 ++++++++------- src/admin/DeleteButton.tsx | 10 ++++ src/admin/EditButton.tsx | 23 ++++++++ src/app/(auth-state)/admin/photos/page.tsx | 65 +++------------------- src/app/(auth-state)/admin/tags/page.tsx | 42 ++++++++++++++ 6 files changed, 134 insertions(+), 77 deletions(-) create mode 100644 src/admin/AdminGrid.tsx create mode 100644 src/admin/DeleteButton.tsx create mode 100644 src/admin/EditButton.tsx create mode 100644 src/app/(auth-state)/admin/tags/page.tsx diff --git a/src/admin/AdminGrid.tsx b/src/admin/AdminGrid.tsx new file mode 100644 index 00000000..16cf493c --- /dev/null +++ b/src/admin/AdminGrid.tsx @@ -0,0 +1,26 @@ +import { cc } from '@/utility/css'; +import { ReactNode } from 'react'; + +export default function AdminGrid ({ + title, + children, +}: { + title?: string, + children: ReactNode, +}) { + return
+ {title && +
+ {title} +
} +
+
+ {children} +
+
+
; +} diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index be8c544d..250f1897 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -1,5 +1,6 @@ 'use client'; +import SiteGrid from '@/components/SiteGrid'; import { cc } from '@/utility/css'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -16,25 +17,29 @@ export default function AdminNav({ const pathname = usePathname(); return ( -
-
- {items.map(({ label, href, count }) => - - {label} - ({count}) - )} -
-
+ +
+ {items.map(({ label, href, count }) => + + {label} + ({count}) + )} +
+
+ } + /> ); } diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx new file mode 100644 index 00000000..4709a58e --- /dev/null +++ b/src/admin/DeleteButton.tsx @@ -0,0 +1,10 @@ +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; + +export default function DeleteButton () { + return ×} + > + Delete + ; +} diff --git a/src/admin/EditButton.tsx b/src/admin/EditButton.tsx new file mode 100644 index 00000000..fc9e1967 --- /dev/null +++ b/src/admin/EditButton.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link'; +import { FaRegEdit } from 'react-icons/fa'; + +export default function EditButton ({ + href, + label = 'Edit', +}: { + href: string, + label?: string, +}) { + return ( + + + + {label} + + + ); +} diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 3555ec3e..91a935b8 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode } from 'react'; +import { Fragment } from 'react'; import PhotoUploadInput from '@/photo/PhotoUploadInput'; import Link from 'next/link'; import PhotoTiny from '@/photo/PhotoTiny'; @@ -11,7 +11,6 @@ import { deleteBlobPhotoAction, syncCacheAction, } from '@/photo/actions'; -import { FaRegEdit } from 'react-icons/fa'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { pathForBlobUrl } from '@/services/blob'; import { @@ -33,12 +32,17 @@ import { PaginationParams, getPaginationForSearchParams, } from '@/site/pagination'; +import AdminGrid from '@/admin/AdminGrid'; +import DeleteButton from '@/admin/DeleteButton'; +import EditButton from '@/admin/EditButton'; export const runtime = 'edge'; const DEBUG_PHOTO_BLOBS = false; -export default async function AdminPage({ searchParams }: PaginationParams) { +export default async function AdminTagsPage({ + searchParams, +}: PaginationParams) { const { offset, limit } = getPaginationForSearchParams(searchParams); const [ @@ -85,7 +89,7 @@ export default async function AdminPage({ searchParams }: PaginationParams) { label={`Photos Files (${blobPhotoUrls.length})`} />}
- + {photos.map(photo => -
- {title} -
-
-
- {children} -
-
-
; -} - -function EditButton ({ - href, - label = 'Edit', -}: { - href: string, - label?: string, -}) { - return ( - - - - {label} - - - ); -} - -function DeleteButton () { - return ×} - > - Delete - ; -} - function BlobUrls ({ blobUrls, label, diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx new file mode 100644 index 00000000..d1cbeff4 --- /dev/null +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -0,0 +1,42 @@ +import FormWithConfirm from '@/components/FormWithConfirm'; +import SiteGrid from '@/components/SiteGrid'; +import { deletePhotoTagGloballyAction } from '@/photo/actions'; +import { getUniqueTagsCached } from '@/cache'; +import AdminGrid from '@/admin/AdminGrid'; +import { Fragment } from 'react'; +import DeleteButton from '@/admin/DeleteButton'; + +export const runtime = 'edge'; + +export default async function AdminPhotosPage() { + const tags = await getUniqueTagsCached(); + + return ( + +
+ + {tags.map(tag => + +
+ {tag} +
+
+
+ + + + + )} + +
+
} + /> + ); +} From cddabc6180a7c47d7abef9c7116bb08b08f2caca Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 5 Oct 2023 23:31:12 -0500 Subject: [PATCH 04/12] Add counts to admin tag management --- src/app/(auth-state)/admin/tags/page.tsx | 16 +++++++++------- src/cache/index.ts | 10 ++++++++++ src/photo/index.ts | 6 ++++-- src/services/postgres.ts | 16 +++++++++++++++- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx index d1cbeff4..6dbeea39 100644 --- a/src/app/(auth-state)/admin/tags/page.tsx +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -1,15 +1,16 @@ import FormWithConfirm from '@/components/FormWithConfirm'; import SiteGrid from '@/components/SiteGrid'; import { deletePhotoTagGloballyAction } from '@/photo/actions'; -import { getUniqueTagsCached } from '@/cache'; import AdminGrid from '@/admin/AdminGrid'; import { Fragment } from 'react'; import DeleteButton from '@/admin/DeleteButton'; +import { photoQuantityText } from '@/photo'; +import { getUniqueTagsWithCountCached } from '@/cache'; export const runtime = 'edge'; export default async function AdminPhotosPage() { - const tags = await getUniqueTagsCached(); + const tags = await getUniqueTagsWithCountCached(); return (
- {tags.map(tag => + {tags.map(({ tag, count }) => -
+
{tag}
-
-
+
+ {photoQuantityText(count, false)} +
diff --git a/src/cache/index.ts b/src/cache/index.ts index 5c959fc8..a13094b9 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -11,6 +11,7 @@ import { getUniqueTags, getPhotosTagDateRange, getPhotosCameraDateRange, + getUniqueTagsWithCount, } from '@/services/postgres'; import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo'; import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob'; @@ -181,6 +182,15 @@ export const getUniqueTagsCached: typeof getUniqueTags = (...args) => } )(); +// eslint-disable-next-line max-len +export const getUniqueTagsWithCountCached: typeof getUniqueTagsWithCount = (...args) => + unstable_cache( + () => getUniqueTagsWithCount(...args), + [KEY_PHOTOS, KEY_TAGS], { + tags: [KEY_PHOTOS, KEY_TAGS], + } + )(); + export const getUniqueCamerasCached: typeof getUniqueCameras = (...args) => unstable_cache( () => getUniqueCameras(...args), diff --git a/src/photo/index.ts b/src/photo/index.ts index c310b148..971fadbe 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -157,8 +157,10 @@ export const titleForPhoto = (photo: Photo) => const photoLabelForCount = (count: number) => count === 1 ? 'Photo' : 'Photos'; -export const photoQuantityText = (count: number) => - `(${count} ${photoLabelForCount(count)})`; +export const photoQuantityText = (count: number, includeParentheses = true) => + includeParentheses + ? `(${count} ${photoLabelForCount(count)})` + : `${count} ${photoLabelForCount(count)}`; export type PhotoDateRange = { start: string, end: string }; diff --git a/src/services/postgres.ts b/src/services/postgres.ts index 3b308026..f3c4f901 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -283,6 +283,17 @@ const sqlGetUniqueTags = async () => sql` ORDER BY tag ASC `.then(({ rows }) => rows.map(row => row.tag as string)); +// Include hidden photos for admin usage +const sqlGetUniqueTagsWithCount = async () => sql` + SELECT DISTINCT unnest(tags) as tag, count(distinct id) as count FROM photos + GROUP BY tag + ORDER BY count ASC +`.then(({ rows }) => rows.map(row => ({ + tag: row.tag as string, + count: parseInt(row.count, 10), + }))); + + const sqlGetUniqueCameras = async () => sql` SELECT DISTINCT make||' '||model as camera, make, model FROM photos WHERE hidden IS NOT TRUE @@ -390,6 +401,9 @@ export const getPhotosCameraDateRange = (camera: Camera) => export const getPhotosCountIncludingHidden = () => safelyQueryPhotos(sqlGetPhotosCountIncludingHidden); -export const getUniqueTags = () => safelyQueryPhotos(sqlGetUniqueTags); +export const getUniqueTags = () => + safelyQueryPhotos(sqlGetUniqueTags); +export const getUniqueTagsWithCount = () => + safelyQueryPhotos(sqlGetUniqueTagsWithCount); export const getUniqueCameras = () => safelyQueryPhotos(sqlGetUniqueCameras); From 712c4ba6d322f62111e85476df73d5079c6a93b9 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 5 Oct 2023 23:34:58 -0500 Subject: [PATCH 05/12] Refine admin tag display --- src/app/(auth-state)/admin/tags/page.tsx | 3 ++- src/services/postgres.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx index 6dbeea39..f31bd33c 100644 --- a/src/app/(auth-state)/admin/tags/page.tsx +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -20,12 +20,13 @@ export default async function AdminPhotosPage() { {tags.map(({ tag, count }) => -
+
{tag}
{photoQuantityText(count, false)}
+
sql` const sqlGetUniqueTagsWithCount = async () => sql` SELECT DISTINCT unnest(tags) as tag, count(distinct id) as count FROM photos GROUP BY tag - ORDER BY count ASC + ORDER BY count DESC `.then(({ rows }) => rows.map(row => ({ tag: row.tag as string, count: parseInt(row.count, 10), From bdad8507c5d1a1393a1d71ea84187fc661168897 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 5 Oct 2023 23:35:50 -0500 Subject: [PATCH 06/12] Lowercase admin tag text --- src/app/(auth-state)/admin/tags/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx index f31bd33c..f15931be 100644 --- a/src/app/(auth-state)/admin/tags/page.tsx +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -31,7 +31,7 @@ export default async function AdminPhotosPage() { action={deletePhotoTagGloballyAction} confirmText={ // eslint-disable-next-line max-len - `Are you sure you want to remove "${tag}?" from ${photoQuantityText(count, false)}?`} + `Are you sure you want to remove "${tag}?" from ${photoQuantityText(count, false).toLowerCase()}?`} > From d2d5a8875c0b5e43cf8c7b6fd137fcdb46bcde5d Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 6 Oct 2023 08:54:23 -0500 Subject: [PATCH 07/12] Add server action to rename tag globally --- src/app/(auth-state)/admin/tags/page.tsx | 8 +++++--- src/photo/actions.ts | 12 ++++++++++++ src/services/postgres.ts | 9 ++++++++- src/tag/PhotoTag.tsx | 3 ++- src/tag/index.ts | 5 ++++- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx index f15931be..36810ec7 100644 --- a/src/app/(auth-state)/admin/tags/page.tsx +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -6,6 +6,8 @@ import { Fragment } from 'react'; import DeleteButton from '@/admin/DeleteButton'; import { photoQuantityText } from '@/photo'; import { getUniqueTagsWithCountCached } from '@/cache'; +import PhotoTag from '@/tag/PhotoTag'; +import { formatTag } from '@/tag'; export const runtime = 'edge'; @@ -21,9 +23,9 @@ export default async function AdminPhotosPage() { {tags.map(({ tag, count }) =>
- {tag} +
-
+
{photoQuantityText(count, false)}
@@ -31,7 +33,7 @@ export default async function AdminPhotosPage() { action={deletePhotoTagGloballyAction} confirmText={ // eslint-disable-next-line max-len - `Are you sure you want to remove "${tag}?" from ${photoQuantityText(count, false).toLowerCase()}?`} + `Are you sure you want to remove "${formatTag(tag)}?" from ${photoQuantityText(count, false).toLowerCase()}?`} > diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 2b1c862f..930c55ad 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -5,6 +5,7 @@ import { sqlInsertPhoto, sqlDeletePhotoTagGlobally, sqlUpdatePhoto, + sqlRenamePhotoTagGlobally, } from '@/services/postgres'; import { convertFormDataToPhoto } from './form'; import { redirect } from 'next/navigation'; @@ -71,6 +72,17 @@ export async function deletePhotoTagGloballyAction(formData: FormData) { revalidatePhotosKey(); } +export async function renamePhotoTagGloballyAction(formData: FormData) { + const tag = formData.get('tag') as string; + const newTag = formData.get('newTag') as string; + + if (tag && newTag && tag !== newTag) { + await sqlRenamePhotoTagGlobally(tag, newTag); + + revalidatePhotosKey(); + } +} + export async function deleteBlobPhotoAction(formData: FormData) { await deleteBlobPhoto(formData.get('url') as string); diff --git a/src/services/postgres.ts b/src/services/postgres.ts index aa8967be..2a30424d 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -135,7 +135,14 @@ export const sqlUpdatePhoto = (photo: PhotoDbInsert) => export const sqlDeletePhotoTagGlobally = (tag: string) => sql` UPDATE photos - SET tags=array_remove(tags, ${tag}) + SET tags=ARRAY_REMOVE(tags, ${tag}) + WHERE ${tag}=ANY(tags) + `; + +export const sqlRenamePhotoTagGlobally = (tag: string, newTag: string) => + sql` + UPDATE photos + SET tags=ARRAY_REPLACE(tags, ${tag}, ${newTag}) WHERE ${tag}=ANY(tags) `; diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index 6d016a74..6526ab05 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { pathForTag } from '@/site/paths'; import { FaTag } from 'react-icons/fa'; import { cc } from '@/utility/css'; +import { formatTag } from '.'; export default function PhotoTag({ tag, @@ -27,7 +28,7 @@ export default function PhotoTag({ )} />} - {tag.replaceAll('-', ' ')} + {formatTag(tag)} ); diff --git a/src/tag/index.ts b/src/tag/index.ts index a3e96f85..8b2d7322 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -7,12 +7,15 @@ import { import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths'; import { capitalizeWords } from '@/utility/string'; +export const formatTag = (tag: string) => + capitalizeWords(tag.replaceAll('-', ' ')); + export const titleForTag = ( tag: string, photos:Photo[], explicitCount?: number, ) => [ - capitalizeWords(tag.replaceAll('-', ' ')), + formatTag(tag), photoQuantityText(explicitCount ?? photos.length), ].join(' '); From 147c6161661efa83c274b7f519846295071ac8da Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 6 Oct 2023 09:19:25 -0500 Subject: [PATCH 08/12] Rename /t route to /tag --- __tests__/path.test.ts | 2 +- .../{t => tag}/[tag]/[photoId]/layout.tsx | 0 .../{t => tag}/[tag]/[photoId]/page.tsx | 0 .../{t => tag}/[tag]/[photoId]/share/page.tsx | 0 .../(static)/{t => tag}/[tag]/image/route.tsx | 0 src/app/(static)/{t => tag}/[tag]/page.tsx | 0 .../(static)/{t => tag}/[tag]/share/page.tsx | 0 src/middleware.ts | 10 ++++- src/site/paths.ts | 42 +++++++++++-------- 9 files changed, 34 insertions(+), 20 deletions(-) rename src/app/(static)/{t => tag}/[tag]/[photoId]/layout.tsx (100%) rename src/app/(static)/{t => tag}/[tag]/[photoId]/page.tsx (100%) rename src/app/(static)/{t => tag}/[tag]/[photoId]/share/page.tsx (100%) rename src/app/(static)/{t => tag}/[tag]/image/route.tsx (100%) rename src/app/(static)/{t => tag}/[tag]/page.tsx (100%) rename src/app/(static)/{t => tag}/[tag]/share/page.tsx (100%) diff --git a/__tests__/path.test.ts b/__tests__/path.test.ts index b994166c..d8e25562 100644 --- a/__tests__/path.test.ts +++ b/__tests__/path.test.ts @@ -28,7 +28,7 @@ const PATH_ADMIN = '/admin/photos'; const PATH_PHOTO = `/p/${PHOTO_ID}`; const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`; -const PATH_TAG = `/t/${TAG}`; +const PATH_TAG = `/tag/${TAG}`; const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`; const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`; diff --git a/src/app/(static)/t/[tag]/[photoId]/layout.tsx b/src/app/(static)/tag/[tag]/[photoId]/layout.tsx similarity index 100% rename from src/app/(static)/t/[tag]/[photoId]/layout.tsx rename to src/app/(static)/tag/[tag]/[photoId]/layout.tsx diff --git a/src/app/(static)/t/[tag]/[photoId]/page.tsx b/src/app/(static)/tag/[tag]/[photoId]/page.tsx similarity index 100% rename from src/app/(static)/t/[tag]/[photoId]/page.tsx rename to src/app/(static)/tag/[tag]/[photoId]/page.tsx diff --git a/src/app/(static)/t/[tag]/[photoId]/share/page.tsx b/src/app/(static)/tag/[tag]/[photoId]/share/page.tsx similarity index 100% rename from src/app/(static)/t/[tag]/[photoId]/share/page.tsx rename to src/app/(static)/tag/[tag]/[photoId]/share/page.tsx diff --git a/src/app/(static)/t/[tag]/image/route.tsx b/src/app/(static)/tag/[tag]/image/route.tsx similarity index 100% rename from src/app/(static)/t/[tag]/image/route.tsx rename to src/app/(static)/tag/[tag]/image/route.tsx diff --git a/src/app/(static)/t/[tag]/page.tsx b/src/app/(static)/tag/[tag]/page.tsx similarity index 100% rename from src/app/(static)/t/[tag]/page.tsx rename to src/app/(static)/tag/[tag]/page.tsx diff --git a/src/app/(static)/t/[tag]/share/page.tsx b/src/app/(static)/tag/[tag]/share/page.tsx similarity index 100% rename from src/app/(static)/t/[tag]/share/page.tsx rename to src/app/(static)/tag/[tag]/share/page.tsx diff --git a/src/middleware.ts b/src/middleware.ts index 7210ac03..2cd56a2c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,7 @@ import { auth } from './auth'; import { NextRequest, NextResponse } from 'next/server'; import type { NextApiRequest, NextApiResponse } from 'next'; +import { PREFIX_PHOTO, PREFIX_TAG } from './site/paths'; export default function middleware(req: NextRequest, res:NextResponse) { const pathname = req.nextUrl.pathname; @@ -11,7 +12,14 @@ export default function middleware(req: NextRequest, res:NextResponse) { // Accept /photos/* paths, but serve /p/* const matches = pathname.match(/^\/photos\/(.+)$/); return NextResponse.rewrite(new URL( - `/p/${matches?.[1]}`, + `${PREFIX_PHOTO}/${matches?.[1]}`, + req.url, + )); + } else if (/^\/t\/(.)+$/.test(pathname)) { + // Accept /t/* paths, but serve /tag/* + const matches = pathname.match(/^\/t\/(.+)$/); + return NextResponse.rewrite(new URL( + `${PREFIX_TAG}/${matches?.[1]}`, req.url, )); } diff --git a/src/site/paths.ts b/src/site/paths.ts index 87480fe7..49df5470 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -7,9 +7,9 @@ import { } from '@/camera'; // Prefixes -const PREFIX_PHOTO = '/p'; -const PREFIX_TAG = '/t'; -const PREFIX_CAMERA = '/shot-on'; +export const PREFIX_PHOTO = '/p'; +export const PREFIX_TAG = '/tag'; +export const PREFIX_CAMERA = '/shot-on'; // Core paths export const PATH_ROOT = '/'; @@ -112,43 +112,43 @@ export const absolutePathForCameraImage= (camera: Camera) => // p/[photoId] export const isPathPhoto = (pathname = '') => - /^\/p\/[^/]+\/?$/.test(pathname); + new RegExp(`^${PREFIX_PHOTO}/[^/]+/?$`).test(pathname); // p/[photoId]/share export const isPathPhotoShare = (pathname = '') => - /^\/p\/[^/]+\/share\/?$/.test(pathname); + new RegExp(`^${PREFIX_PHOTO}/[^/]+/${SHARE}/?$`).test(pathname); // t/[tag] export const isPathTag = (pathname = '') => - /^\/t\/[^/]+\/?$/.test(pathname); + new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname); // t/[tag]/share export const isPathTagShare = (pathname = '') => - /^\/t\/[^/]+\/share\/?$/.test(pathname); + new RegExp(`^${PREFIX_TAG}/[^/]+/${SHARE}/?$`).test(pathname); // t/[tag]/[photoId] export const isPathTagPhoto = (pathname = '') => - /^\/t\/[^/]+\/[^/]+\/?$/.test(pathname); + new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname); // t/[tag]/[photoId]/share export const isPathTagPhotoShare = (pathname = '') => - /^\/t\/[^/]+\/[^/]+\/share\/?$/.test(pathname); + new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname); // shot-on/[camera] export const isPathCamera = (pathname = '') => - /^\/shot-on\/[^/]+\/?$/.test(pathname); + new RegExp(`^${PREFIX_CAMERA}/[^/]+/?$`).test(pathname); // shot-on/[camera]/share export const isPathCameraShare = (pathname = '') => - /^\/shot-on\/[^/]+\/share\/?$/.test(pathname); + new RegExp(`^${PREFIX_CAMERA}/[^/]+/${SHARE}/?$`).test(pathname); // shot-on/[camera]/[photoId] export const isPathCameraPhoto = (pathname = '') => - /^\/shot-on\/[^/]+\/[^/]+\/?$/.test(pathname); + new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname); // shot-on/[camera]/[photoId]/share export const isPathCameraPhotoShare = (pathname = '') => - /^\/shot-on\/[^/]+\/[^/]+\/share\/?$/.test(pathname); + new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname); export const isPathGrid = (pathname = '') => pathname.startsWith(PATH_GRID); @@ -168,15 +168,21 @@ export const getPathComponents = (pathname = ''): { tag?: string camera?: Camera } => { - const photoIdFromPhoto = pathname.match(/^\/p\/([^/]+)/)?.[1]; - const photoIdFromTag = pathname.match(/^\/t\/[^/]+\/((?!share)[^/]+)/)?.[1]; // eslint-disable-next-line max-len - const photoIdFromCamera = pathname.match(/^\/shot-on\/[^/]+\/((?!share)[^/]+)/)?.[1]; - const tag = pathname.match(/^\/t\/([^/]+)/)?.[1]; - const cameraString = pathname.match(/^\/shot-on\/([^/]+)/)?.[1]; + const photoIdFromPhoto = pathname.match(new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; + // eslint-disable-next-line max-len + const photoIdFromTag = pathname.match(new RegExp(`^${PREFIX_TAG}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; + // eslint-disable-next-line max-len + const photoIdFromCamera = pathname.match(new RegExp(`^${PREFIX_CAMERA}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; + // eslint-disable-next-line max-len + const tag = pathname.match(new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; + // eslint-disable-next-line max-len + const cameraString = pathname.match(new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; + const camera = cameraString ? getCameraFromKey(cameraString) : undefined; + return { photoId: ( photoIdFromPhoto || From 7c5ec62bdaae32a7a32718832186d0039d48e182 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 6 Oct 2023 12:54:54 -0500 Subject: [PATCH 09/12] Allow tags to be edited globally --- src/admin/AdminNav.tsx | 2 +- .../admin/photos/[photoId]/edit/page.tsx | 8 +- src/app/(auth-state)/admin/photos/page.tsx | 4 +- .../admin/tags/[tag]/edit/page.tsx | 27 +++++++ src/app/(auth-state)/admin/tags/page.tsx | 4 +- src/auth/SignInForm.tsx | 3 +- src/components/AdminChildPage.tsx | 18 +++-- src/middleware.ts | 11 ++- src/photo/PhotoForm.tsx | 5 +- src/photo/actions.ts | 13 +-- src/services/postgres.ts | 4 +- src/site/paths.ts | 10 ++- src/tag/TagForm.tsx | 79 +++++++++++++++++++ src/utility/string.ts | 8 +- 14 files changed, 164 insertions(+), 32 deletions(-) create mode 100644 src/app/(auth-state)/admin/tags/[tag]/edit/page.tsx create mode 100644 src/tag/TagForm.tsx diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 250f1897..f4c5f9e0 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -20,7 +20,7 @@ export default function AdminNav({
+
- + t.tag === tag); + + if (!tagData) { redirect(PATH_ADMIN); } + + return ( + + + + ); +}; diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx index 36810ec7..34603124 100644 --- a/src/app/(auth-state)/admin/tags/page.tsx +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -8,6 +8,8 @@ import { photoQuantityText } from '@/photo'; import { getUniqueTagsWithCountCached } from '@/cache'; import PhotoTag from '@/tag/PhotoTag'; import { formatTag } from '@/tag'; +import EditButton from '@/admin/EditButton'; +import { pathForAdminTagEdit } from '@/site/paths'; export const runtime = 'edge'; @@ -28,7 +30,7 @@ export default async function AdminPhotosPage() {
{photoQuantityText(count, false)}
-
+ setIsSigningIn(false)); diff --git a/src/components/AdminChildPage.tsx b/src/components/AdminChildPage.tsx index 159618ad..a5dc11a7 100644 --- a/src/components/AdminChildPage.tsx +++ b/src/components/AdminChildPage.tsx @@ -3,16 +3,24 @@ import Link from 'next/link'; import { FiArrowLeft } from 'react-icons/fi'; function AdminChildPage({ + backPath, + backLabel, children, }: { + backPath?: string + backLabel?: string children: ReactNode, }) { return ( -
- - - Admin - +
+ {backPath && + + + {backLabel || 'Back'} + }
{children}
diff --git a/src/middleware.ts b/src/middleware.ts index 2cd56a2c..cd6aa351 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,13 +1,18 @@ import { auth } from './auth'; import { NextRequest, NextResponse } from 'next/server'; import type { NextApiRequest, NextApiResponse } from 'next'; -import { PREFIX_PHOTO, PREFIX_TAG } from './site/paths'; +import { + PATH_ADMIN, + PATH_ADMIN_PHOTOS, + PREFIX_PHOTO, + PREFIX_TAG, +} from './site/paths'; export default function middleware(req: NextRequest, res:NextResponse) { const pathname = req.nextUrl.pathname; - if (pathname === '/admin') { - return NextResponse.redirect(new URL('/admin/photos', req.url)); + if (pathname === PATH_ADMIN) { + return NextResponse.redirect(new URL(PATH_ADMIN_PHOTOS, req.url)); } else if (/^\/photos\/(.)+$/.test(pathname)) { // Accept /photos/* paths, but serve /p/* const matches = pathname.match(/^\/photos\/(.+)$/); diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 8014b9e5..8e91c28f 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -12,6 +12,7 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import Link from 'next/link'; import { cc } from '@/utility/css'; import CanvasBlurCapture from '@/components/CanvasBlurCapture'; +import { PATH_ADMIN_PHOTOS } from '@/site/paths'; const THUMBNAIL_WIDTH = 300; const THUMBNAIL_HEIGHT = 200; @@ -108,11 +109,11 @@ export default function PhotoForm({ readOnly hidden />} -
+
{type === 'edit' && Cancel } diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 930c55ad..ed6e071f 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -20,6 +20,7 @@ import { } from '@/cache'; import { IS_PRO_MODE } from '@/site/config'; import { getNextImageUrlForRequest } from '@/utility/image'; +import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; export async function createPhotoAction(formData: FormData) { const requestOrigin = formData.get('requestOrigin') as string | undefined; @@ -42,7 +43,7 @@ export async function createPhotoAction(formData: FormData) { revalidateAllKeys(); - redirect('/admin/photos'); + redirect(PATH_ADMIN_PHOTOS); } export async function updatePhotoAction(formData: FormData) { @@ -52,7 +53,7 @@ export async function updatePhotoAction(formData: FormData) { revalidatePhotosKey(); - redirect('/admin/photos'); + redirect(PATH_ADMIN_PHOTOS); } export async function deletePhotoAction(formData: FormData) { @@ -74,12 +75,12 @@ export async function deletePhotoTagGloballyAction(formData: FormData) { export async function renamePhotoTagGloballyAction(formData: FormData) { const tag = formData.get('tag') as string; - const newTag = formData.get('newTag') as string; - - if (tag && newTag && tag !== newTag) { - await sqlRenamePhotoTagGlobally(tag, newTag); + const updatedTag = formData.get('updatedTag') as string; + if (tag && updatedTag && tag !== updatedTag) { + await sqlRenamePhotoTagGlobally(tag, updatedTag); revalidatePhotosKey(); + redirect(PATH_ADMIN_TAGS); } } diff --git a/src/services/postgres.ts b/src/services/postgres.ts index 2a30424d..7df26527 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -139,10 +139,10 @@ export const sqlDeletePhotoTagGlobally = (tag: string) => WHERE ${tag}=ANY(tags) `; -export const sqlRenamePhotoTagGlobally = (tag: string, newTag: string) => +export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) => sql` UPDATE photos - SET tags=ARRAY_REPLACE(tags, ${tag}, ${newTag}) + SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag}) WHERE ${tag}=ANY(tags) `; diff --git a/src/site/paths.ts b/src/site/paths.ts index 49df5470..d1868093 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -28,6 +28,7 @@ export const PATH_ADMIN_UPLOAD_BLOB = `${PATH_ADMIN_UPLOAD}/blob`; // Modifiers const SHARE = 'share'; const NEXT = 'next'; +const EDIT = 'edit'; // Absolute paths export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`; @@ -44,6 +45,12 @@ export const pathForGrid = (next?: number) => export const pathForAdminPhotos = (next?: number) => pathWithNext(PATH_ADMIN_PHOTOS, next); +export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) => + `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`; + +export const pathForAdminTagEdit = (tag: string) => + `${PATH_ADMIN_TAGS}/${tag}/${EDIT}`; + export const pathForOg = (next?: number) => pathWithNext(PATH_OG, next); @@ -70,9 +77,6 @@ export const pathForPhotoShare = ( ) => `${pathForPhoto(photo, tag, camera)}/${SHARE}`; -export const pathForPhotoEdit = (photo: PhotoOrPhotoId) => - `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`; - export const pathForTag = (tag: string, next?: number) => pathWithNext( `${PREFIX_TAG}/${tag}`, diff --git a/src/tag/TagForm.tsx b/src/tag/TagForm.tsx new file mode 100644 index 00000000..c572b807 --- /dev/null +++ b/src/tag/TagForm.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { photoQuantityText } from '@/photo'; +import PhotoTag from './PhotoTag'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import Link from 'next/link'; +import { PATH_ADMIN_TAGS } from '@/site/paths'; +import FieldSetWithStatus from '@/components/FieldSetWithStatus'; +import { useMemo, useState } from 'react'; +import { renamePhotoTagGloballyAction } from '@/photo/actions'; +import { parameterize } from '@/utility/string'; + +export default function TagForm({ + tag, + count, +}: { + tag: string + count: number +}) { + const [updatedTagRaw, setUpdatedTagRaw] = useState(tag); + + const updatedTag = useMemo(() => + parameterize(updatedTagRaw) + , [updatedTagRaw]); + + const isFormValid = ( + updatedTag && + updatedTag !== tag + ); + + return ( +
+
+ +
+ {photoQuantityText(count, false)} +
+
+
+ + {/* Form data: tag to be replaced */} + + {/* Form data: updated tag */} + +
+ + Cancel + + + Update + +
+ +
+ ); +} diff --git a/src/utility/string.ts b/src/utility/string.ts index 73590a36..74b3b94f 100644 --- a/src/utility/string.ts +++ b/src/utility/string.ts @@ -1,9 +1,9 @@ export const convertStringToArray = ( string?: string, - parameterize = true, + shouldParameterize = true, ) => string - ? string.split(',').map(tag => parameterize - ? tag.trim().replaceAll(' ', '-').toLowerCase() + ? string.split(',').map(tag => shouldParameterize + ? parameterize(tag) : tag.trim()) : undefined; @@ -19,5 +19,5 @@ export const capitalizeWords = (string: string) => export const parameterize = (string: string) => string .trim() - .replaceAll(' ', '-') + .replaceAll(/\s+/g, '-') .toLowerCase(); From 9e3989e0c1c85e9167099243981dfc93467a79b5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 6 Oct 2023 13:35:01 -0500 Subject: [PATCH 10/12] Show photos when editing a tag --- src/admin/AdminNav.tsx | 2 +- src/app/(auth-state)/admin/layout.tsx | 2 +- .../admin/photos/[photoId]/edit/page.tsx | 1 + .../admin/tags/[tag]/edit/page.tsx | 32 +++++-- src/components/AdminChildPage.tsx | 45 +++++++--- src/photo/PhotoGrid.tsx | 6 +- src/tag/TagForm.tsx | 86 ++++++++----------- 7 files changed, 104 insertions(+), 70 deletions(-) diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index f4c5f9e0..bb44edda 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -20,7 +20,7 @@ export default function AdminNav({
+
{children}
diff --git a/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx b/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx index 0fda5590..a5fc1881 100644 --- a/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx +++ b/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx @@ -20,6 +20,7 @@ export default async function PhotoPageEdit({ params: { photoId } }: Props) { t.tag === tag); + const [ + count, + photos, + ] = await Promise.all([ + getPhotosTagCount(tag), + getPhotosCached({ tag }), + ]); - if (!tagData) { redirect(PATH_ADMIN); } + if (count === 0) { redirect(PATH_ADMIN); } return ( + +
+ {photoQuantityText(count, false)} +
+
} > - +
+ + +
); }; diff --git a/src/components/AdminChildPage.tsx b/src/components/AdminChildPage.tsx index a5dc11a7..e495501d 100644 --- a/src/components/AdminChildPage.tsx +++ b/src/components/AdminChildPage.tsx @@ -1,30 +1,49 @@ import { ReactNode } from 'react'; import Link from 'next/link'; import { FiArrowLeft } from 'react-icons/fi'; +import SiteGrid from './SiteGrid'; +import { cc } from '@/utility/css'; function AdminChildPage({ backPath, backLabel, + breadcrumb, children, }: { backPath?: string backLabel?: string + breadcrumb?: ReactNode children: ReactNode, }) { return ( -
- {backPath && - - - {backLabel || 'Back'} - } -
- {children} -
-
+ + {backPath && +
+ + + {backLabel || 'Back'} + + {breadcrumb && + <> + / + + {breadcrumb} + + } +
} +
+ {children} +
+
} + /> ); }; diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index 8c91da07..ae5fcad5 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -15,6 +15,7 @@ export default function PhotoGrid({ animateOnFirstLoadOnly, staggerOnFirstLoadOnly = true, showMorePath, + small, }: { photos: Photo[] selectedPhoto?: Photo @@ -25,13 +26,16 @@ export default function PhotoGrid({ animateOnFirstLoadOnly?: boolean staggerOnFirstLoadOnly?: boolean showMorePath?: string + small?: boolean }) { return (
-
- -
- {photoQuantityText(count, false)} -
+
+ + {/* Form data: tag to be replaced */} + + {/* Form data: updated tag */} + +
+ + Cancel + + + Update +
- - - {/* Form data: tag to be replaced */} - - {/* Form data: updated tag */} - -
- - Cancel - - - Update - -
- -
+ ); } From 866992df44a91be568b47d07e6089ef1c78ad648 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 6 Oct 2023 13:37:24 -0500 Subject: [PATCH 11/12] Bump dependencies --- package.json | 6 +++--- pnpm-lock.yaml | 34 ++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 4c1b66a9..58316473 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,11 @@ "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.5", "@types/node": "^20.8.2", - "@types/react": "18.2.24", - "@types/react-dom": "18.2.8", + "@types/react": "18.2.25", + "@types/react-dom": "18.2.10", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", - "@vercel/analytics": "^1.0.2", + "@vercel/analytics": "^1.1.0", "@vercel/blob": "^0.13.0", "@vercel/postgres": "0.5.0", "autoprefixer": "10.4.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca9a683a..2dbcb748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,11 +24,11 @@ dependencies: specifier: ^20.8.2 version: 20.8.2 '@types/react': - specifier: 18.2.24 - version: 18.2.24 + specifier: 18.2.25 + version: 18.2.25 '@types/react-dom': - specifier: 18.2.8 - version: 18.2.8 + specifier: 18.2.10 + version: 18.2.10 '@typescript-eslint/eslint-plugin': specifier: ^6.7.4 version: 6.7.4(@typescript-eslint/parser@6.7.4)(eslint@8.50.0)(typescript@5.2.2) @@ -36,8 +36,8 @@ dependencies: specifier: ^6.7.4 version: 6.7.4(eslint@8.50.0)(typescript@5.2.2) '@vercel/analytics': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.1.0 + version: 1.1.0 '@vercel/blob': specifier: ^0.13.0 version: 0.13.0 @@ -1035,7 +1035,7 @@ packages: dependencies: '@babel/runtime': 7.22.15 '@testing-library/dom': 9.3.3 - '@types/react-dom': 18.2.8 + '@types/react-dom': 18.2.10 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -1139,14 +1139,14 @@ packages: resolution: {integrity: sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg==} dev: false - /@types/react-dom@18.2.8: - resolution: {integrity: sha512-bAIvO5lN/U8sPGvs1Xm61rlRHHaq5rp5N3kp9C+NJ/Q41P8iqjkXSu0+/qu8POsjH9pNWb0OYabFez7taP7omw==} + /@types/react-dom@18.2.10: + resolution: {integrity: sha512-5VEC5RgXIk1HHdyN1pHlg0cOqnxHzvPGpMMyGAP5qSaDRmyZNDaQ0kkVAkK6NYlDhP6YBID3llaXlmAS/mdgCA==} dependencies: - '@types/react': 18.2.24 + '@types/react': 18.2.25 dev: false - /@types/react@18.2.24: - resolution: {integrity: sha512-Ee0Jt4sbJxMu1iDcetZEIKQr99J1Zfb6D4F3qfUWoR1JpInkY1Wdg4WwCyBjL257D0+jGqSl1twBjV8iCaC0Aw==} + /@types/react@18.2.25: + resolution: {integrity: sha512-24xqse6+VByVLIr+xWaQ9muX1B4bXJKXBbjszbld/UEDslGLY53+ZucF44HCmLbMPejTzGG9XgR+3m2/Wqu1kw==} dependencies: '@types/prop-types': 15.7.6 '@types/scheduler': 0.16.3 @@ -1310,8 +1310,10 @@ packages: eslint-visitor-keys: 3.4.3 dev: false - /@vercel/analytics@1.0.2: - resolution: {integrity: sha512-BZFxVrv24VbNNl5xMxqUojQIegEeXMI6rX3rg1uVLYUEXsuKNBSAEQf4BWEcjQDp/8aYJOj6m8V4PUA3x/cxgg==} + /@vercel/analytics@1.1.0: + resolution: {integrity: sha512-k5ePYZPxitxxD1eJenPUUuH3qK+EtaA9OVD3oO0BRbyT/LarmZF0qdkptRSidip1arQxsTEIWvHbTuj1oksl+Q==} + dependencies: + server-only: 0.0.1 dev: false /@vercel/blob@0.13.0: @@ -4759,6 +4761,10 @@ packages: lru-cache: 6.0.0 dev: false + /server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + dev: false + /set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} From 25549a3f1e12bb11f21f2a7ab91b71aa8b66ce29 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 6 Oct 2023 14:46:57 -0500 Subject: [PATCH 12/12] Update path documentation --- src/site/paths.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/site/paths.ts b/src/site/paths.ts index d1868093..312d451e 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -122,19 +122,19 @@ export const isPathPhoto = (pathname = '') => export const isPathPhotoShare = (pathname = '') => new RegExp(`^${PREFIX_PHOTO}/[^/]+/${SHARE}/?$`).test(pathname); -// t/[tag] +// tag/[tag] export const isPathTag = (pathname = '') => new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname); -// t/[tag]/share +// tag/[tag]/share export const isPathTagShare = (pathname = '') => new RegExp(`^${PREFIX_TAG}/[^/]+/${SHARE}/?$`).test(pathname); -// t/[tag]/[photoId] +// tag/[tag]/[photoId] export const isPathTagPhoto = (pathname = '') => new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname); -// t/[tag]/[photoId]/share +// tag/[tag]/[photoId]/share export const isPathTagPhotoShare = (pathname = '') => new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname); @@ -172,16 +172,16 @@ export const getPathComponents = (pathname = ''): { tag?: string camera?: Camera } => { - // eslint-disable-next-line max-len - const photoIdFromPhoto = pathname.match(new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; - // eslint-disable-next-line max-len - const photoIdFromTag = pathname.match(new RegExp(`^${PREFIX_TAG}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; - // eslint-disable-next-line max-len - const photoIdFromCamera = pathname.match(new RegExp(`^${PREFIX_CAMERA}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; - // eslint-disable-next-line max-len - const tag = pathname.match(new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; - // eslint-disable-next-line max-len - const cameraString = pathname.match(new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; + const photoIdFromPhoto = pathname.match( + new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; + const photoIdFromTag = pathname.match( + new RegExp(`^${PREFIX_TAG}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; + const photoIdFromCamera = pathname.match( + new RegExp(`^${PREFIX_CAMERA}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; + const tag = pathname.match( + new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; + const cameraString = pathname.match( + new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; const camera = cameraString ? getCameraFromKey(cameraString)