Allow tags to be edited globally

This commit is contained in:
Sam Becker 2023-10-06 12:54:54 -05:00
parent 147c616166
commit 7c5ec62bda
14 changed files with 164 additions and 32 deletions

View File

@ -20,7 +20,7 @@ export default function AdminNav({
<SiteGrid
contentMain={
<div className={cc(
'border-b border-gray-900 pb-2',
'border-b border-gray-800 pb-3',
)}>
<div className={cc(
'flex gap-2 md:gap-4',

View File

@ -3,6 +3,7 @@ import { convertPhotoToFormData } from '@/photo/form';
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotoCached } from '@/cache';
import { PATH_ADMIN, PATH_ADMIN_PHOTOS } from '@/site/paths';
export const runtime = 'edge';
@ -13,10 +14,13 @@ interface Props {
export default async function PhotoPageEdit({ params: { photoId } }: Props) {
const photo = await getPhotoCached(photoId);
if (!photo) { redirect('/admin'); }
if (!photo) { redirect(PATH_ADMIN); }
return (
<AdminChildPage>
<AdminChildPage
backPath={PATH_ADMIN_PHOTOS}
backLabel="Photos"
>
<PhotoForm
type="edit"
initialPhotoForm={convertPhotoToFormData(photo)}

View File

@ -16,7 +16,7 @@ import { pathForBlobUrl } from '@/services/blob';
import {
pathForAdminPhotos,
pathForPhoto,
pathForPhotoEdit,
pathForAdminPhotoEdit,
} from '@/site/paths';
import { titleForPhoto } from '@/photo';
import MorePhotos from '@/components/MorePhotos';
@ -132,7 +132,7 @@ export default async function AdminTagsPage({
{photo.takenAtNaive}
</div>
</div>
<EditButton href={pathForPhotoEdit(photo)} />
<EditButton href={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={deletePhotoAction}
confirmText={

View File

@ -0,0 +1,27 @@
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getUniqueTagsWithCountCached } from '@/cache';
import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS } from '@/site/paths';
export const runtime = 'edge';
interface Props {
params: { tag: string }
}
export default async function PhotoPageEdit({ params: { tag } }: Props) {
const tags = await getUniqueTagsWithCountCached();
const tagData = tags.find(t => t.tag === tag);
if (!tagData) { redirect(PATH_ADMIN); }
return (
<AdminChildPage
backPath={PATH_ADMIN_TAGS}
backLabel="Tags"
>
<TagForm {...tagData} />
</AdminChildPage>
);
};

View File

@ -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() {
<div className="text-dim uppercase">
{photoQuantityText(count, false)}
</div>
<div />
<EditButton href={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={

View File

@ -3,6 +3,7 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import InfoBlock from '@/components/InfoBlock';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { signIn } from 'next-auth/react';
import { useLayoutEffect, useRef, useState } from 'react';
@ -28,7 +29,7 @@ export default function SignInForm() {
{
email,
password,
callbackUrl: '/admin/photos',
callbackUrl: PATH_ADMIN_PHOTOS,
},
)
.catch(() => setIsSigningIn(false));

View File

@ -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 (
<div className="space-y-5">
<Link href="/admin/photos" className="flex gap-1 items-center">
<FiArrowLeft size={16} />
Admin
</Link>
<div className="space-y-8">
{backPath &&
<Link
href={backPath}
className="flex gap-1 items-center"
>
<FiArrowLeft size={16} />
{backLabel || 'Back'}
</Link>}
<div>
{children}
</div>

View File

@ -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\/(.+)$/);

View File

@ -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
/>}
<div className="flex gap-4">
<div className="flex gap-3">
{type === 'edit' &&
<Link
className="button"
href="/admin/photos"
href={PATH_ADMIN_PHOTOS}
>
Cancel
</Link>}

View File

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

View File

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

View File

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

79
src/tag/TagForm.tsx Normal file
View File

@ -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 (
<div className="space-y-8 max-w-[38rem]">
<div className="flex item gap-2">
<PhotoTag {...{ tag }} />
<div className="text-dim uppercase">
{photoQuantityText(count, false)}
</div>
</div>
<form
action={renamePhotoTagGloballyAction}
className="space-y-11"
>
<FieldSetWithStatus
id="updatedTagRaw"
label="New Tag Name"
value={updatedTagRaw}
onChange={setUpdatedTagRaw}
/>
{/* Form data: tag to be replaced */}
<input
name="tag"
value={tag}
hidden
readOnly
/>
{/* Form data: updated tag */}
<input
name="updatedTag"
value={updatedTag}
hidden
readOnly
/>
<div className="flex gap-3">
<Link
className="button"
href={PATH_ADMIN_TAGS}
>
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid}
>
Update
</SubmitButtonWithStatus>
</div>
</form>
</div>
);
}

View File

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