,
].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/middleware.ts b/src/middleware.ts
index 7210ac03..cd6aa351 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,17 +1,30 @@
import { auth } from './auth';
import { NextRequest, NextResponse } from 'next/server';
import type { NextApiRequest, NextApiResponse } from 'next';
+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\/(.+)$/);
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/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/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 (
{selectedPhotoIndex !== undefined
@@ -48,7 +48,7 @@ export default function PhotoHeader({
{start === end
? start
diff --git a/src/photo/actions.ts b/src/photo/actions.ts
index 2b1c862f..ed6e071f 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';
@@ -19,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;
@@ -41,7 +43,7 @@ export async function createPhotoAction(formData: FormData) {
revalidateAllKeys();
- redirect('/admin/photos');
+ redirect(PATH_ADMIN_PHOTOS);
}
export async function updatePhotoAction(formData: FormData) {
@@ -51,7 +53,7 @@ export async function updatePhotoAction(formData: FormData) {
revalidatePhotosKey();
- redirect('/admin/photos');
+ redirect(PATH_ADMIN_PHOTOS);
}
export async function deletePhotoAction(formData: FormData) {
@@ -71,6 +73,17 @@ export async function deletePhotoTagGloballyAction(formData: FormData) {
revalidatePhotosKey();
}
+export async function renamePhotoTagGloballyAction(formData: FormData) {
+ const tag = formData.get('tag') as string;
+ const updatedTag = formData.get('updatedTag') as string;
+
+ if (tag && updatedTag && tag !== updatedTag) {
+ await sqlRenamePhotoTagGlobally(tag, updatedTag);
+ revalidatePhotosKey();
+ redirect(PATH_ADMIN_TAGS);
+ }
+}
+
export async function deleteBlobPhotoAction(formData: FormData) {
await deleteBlobPhoto(formData.get('url') as string);
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/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/services/postgres.ts b/src/services/postgres.ts
index 3b308026..7df26527 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, updatedTag: string) =>
+ sql`
+ UPDATE photos
+ SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
WHERE ${tag}=ANY(tags)
`;
@@ -283,6 +290,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 DESC
+`.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 +408,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);
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..312d451e 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 = '/';
@@ -19,14 +19,16 @@ 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';
const NEXT = 'next';
+const EDIT = 'edit';
// Absolute paths
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
@@ -43,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);
@@ -69,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}`,
@@ -111,43 +116,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]
+// tag/[tag]
export const isPathTag = (pathname = '') =>
- /^\/t\/[^/]+\/?$/.test(pathname);
+ new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);
-// t/[tag]/share
+// tag/[tag]/share
export const isPathTagShare = (pathname = '') =>
- /^\/t\/[^/]+\/share\/?$/.test(pathname);
+ new RegExp(`^${PREFIX_TAG}/[^/]+/${SHARE}/?$`).test(pathname);
-// t/[tag]/[photoId]
+// tag/[tag]/[photoId]
export const isPathTagPhoto = (pathname = '') =>
- /^\/t\/[^/]+\/[^/]+\/?$/.test(pathname);
+ new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname);
-// t/[tag]/[photoId]/share
+// tag/[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);
@@ -167,15 +172,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];
+ 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)
: undefined;
+
return {
photoId: (
photoIdFromPhoto ||
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/TagForm.tsx b/src/tag/TagForm.tsx
new file mode 100644
index 00000000..869519d5
--- /dev/null
+++ b/src/tag/TagForm.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+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,
+}: {
+ tag: string
+}) {
+ const [updatedTagRaw, setUpdatedTagRaw] = useState(tag);
+
+ const updatedTag = useMemo(() =>
+ parameterize(updatedTagRaw)
+ , [updatedTagRaw]);
+
+ const isFormValid = (
+ updatedTag &&
+ updatedTag !== tag
+ );
+
+ return (
+
+ );
+}
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(' ');
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();