Allow tags to be edited globally
This commit is contained in:
parent
147c616166
commit
7c5ec62bda
@ -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',
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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={
|
||||
|
||||
27
src/app/(auth-state)/admin/tags/[tag]/edit/page.tsx
Normal file
27
src/app/(auth-state)/admin/tags/[tag]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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={
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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\/(.+)$/);
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
`;
|
||||
|
||||
|
||||
@ -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
79
src/tag/TagForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user