Merge pull request #2 from sambecker/next

Add global tag management to admin panel
This commit is contained in:
Sam Becker 2023-10-06 18:01:04 -05:00 committed by GitHub
commit 1fd41462e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 616 additions and 229 deletions

View File

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

View File

@ -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",

34
pnpm-lock.yaml generated
View File

@ -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'}

26
src/admin/AdminGrid.tsx Normal file
View File

@ -0,0 +1,26 @@
import { cc } from '@/utility/css';
import { ReactNode } from 'react';
export default function AdminGrid ({
title,
children,
}: {
title?: string,
children: ReactNode,
}) {
return <div className="space-y-4">
{title &&
<div className="font-bold">
{title}
</div>}
<div className="min-w-[14rem] overflow-x-scroll">
<div className={cc(
'w-full',
'grid grid-cols-[auto_1fr_auto_auto] ',
'gap-2 sm:gap-3 items-center',
)}>
{children}
</div>
</div>
</div>;
}

45
src/admin/AdminNav.tsx Normal file
View File

@ -0,0 +1,45 @@
'use client';
import SiteGrid from '@/components/SiteGrid';
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 (
<SiteGrid
contentMain={
<div className={cc(
'border-b border-gray-200 dark:border-gray-800 pb-3',
)}>
<div className={cc(
'flex gap-2 md:gap-4',
)}>
{items.map(({ label, href, count }) =>
<Link
key={label}
href={href}
className={cc(
'flex gap-0.5',
pathname.startsWith(href) ? 'font-bold' : 'text-dim',
)}
>
<span>{label}</span>
<span>({count})</span>
</Link>)}
</div>
</div>
}
/>
);
}

View File

@ -0,0 +1,10 @@
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
export default function DeleteButton () {
return <SubmitButtonWithStatus
title="Delete"
icon={<span className="inline-flex text-[18px]">×</span>}
>
Delete
</SubmitButtonWithStatus>;
}

23
src/admin/EditButton.tsx Normal file
View File

@ -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 (
<Link
title="Edit"
href={href}
className="button"
>
<FaRegEdit className="translate-y-[-0.5px]" />
<span className="hidden sm:inline-block">
{label}
</span>
</Link>
);
}

View File

@ -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 (
<div className="mt-4 space-y-5">
<AdminNav items={navItems} />
{children}
</div>
);
}

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,14 @@ 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"
breadcrumb={photo.title || photo.id}
>
<PhotoForm
type="edit"
initialPhotoForm={convertPhotoToFormData(photo)}

View File

@ -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,13 +11,12 @@ import {
deleteBlobPhotoAction,
syncCacheAction,
} from '@/photo/actions';
import { FaRegEdit } from 'react-icons/fa';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { pathForBlobUrl } from '@/services/blob';
import {
pathForAdminPhotos,
pathForPhoto,
pathForPhotoEdit,
pathForAdminPhotoEdit,
} from '@/site/paths';
import { titleForPhoto } from '@/photo';
import MorePhotos from '@/components/MorePhotos';
@ -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 [
@ -58,152 +62,97 @@ export default async function AdminPage({ searchParams }: PaginationParams) {
return (
<SiteGrid
contentMain={
<div className="mt-4 space-y-4">
<div className="space-y-8">
<div className="flex items-center">
<div className="flex-grow">
<PhotoUploadInput />
</div>
<form
className="hidden md:block"
action={syncCacheAction}
<div className="space-y-6">
<div className="flex items-center">
<div className="flex-grow">
<PhotoUploadInput />
</div>
<form
className="hidden md:block"
action={syncCacheAction}
>
<SubmitButtonWithStatus
icon={<BiTrash />}
>
<SubmitButtonWithStatus
icon={<BiTrash />}
>
Clear Cache
</SubmitButtonWithStatus>
</form>
</div>
{blobUploadUrls.length > 0 &&
<BlobUrls
blobUrls={blobUploadUrls}
label={`Uploads Files (${blobUploadUrls.length})`}
/>}
{blobPhotoUrls.length > 0 &&
<BlobUrls
blobUrls={blobPhotoUrls}
label={`Photos Files (${blobPhotoUrls.length})`}
/>}
<div className="space-y-4">
<AdminGrid title={`Photos (${count})`}>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny
className={cc(
'rounded-sm overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
photo={photo}
/>
<div className="flex flex-col md:flex-row">
<Link
key={photo.id}
href={pathForPhoto(photo)}
className="sm:w-[50%] flex items-center gap-2"
>
<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',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className={cc(
'sm:w-[50%] uppercase',
'text-gray-400 dark:text-gray-500',
)}>
{photo.takenAtNaive}
</div>
</div>
<EditButton href={pathForPhotoEdit(photo)} />
<FormWithConfirm
action={deletePhotoAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to delete "${titleForPhoto(photo)}?"`}
Clear Cache
</SubmitButtonWithStatus>
</form>
</div>
{blobUploadUrls.length > 0 &&
<BlobUrls
blobUrls={blobUploadUrls}
label={`Uploads Files (${blobUploadUrls.length})`}
/>}
{blobPhotoUrls.length > 0 &&
<BlobUrls
blobUrls={blobPhotoUrls}
label={`Photos Files (${blobPhotoUrls.length})`}
/>}
<div className="space-y-4">
<AdminGrid>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny
className={cc(
'rounded-sm overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
photo={photo}
/>
<div className="flex flex-col md:flex-row">
<Link
key={photo.id}
href={pathForPhoto(photo)}
className="sm:w-[50%] flex items-center gap-2"
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton />
</FormWithConfirm>
</Fragment>)}
</AdminGrid>
{showMorePhotos &&
<MorePhotos path={pathForAdminPhotos(offset + 1)} />}
</div>
<span className={cc(
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<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',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className={cc(
'sm:w-[50%] uppercase',
'text-dim',
)}>
{photo.takenAtNaive}
</div>
</div>
<EditButton href={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={deletePhotoAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to delete "${titleForPhoto(photo)}?"`}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton />
</FormWithConfirm>
</Fragment>)}
</AdminGrid>
{showMorePhotos &&
<MorePhotos path={pathForAdminPhotos(offset + 1)} />}
</div>
</div>}
/>
);
}
function AdminGrid ({
title,
children,
}: {
title: string,
children: ReactNode,
}) {
return <div className="space-y-4">
<div className="font-bold">
{title}
</div>
<div className="min-w-[14rem] overflow-x-scroll">
<div className={cc(
'w-full',
'grid grid-cols-[auto_1fr_auto_auto] ',
'gap-2 sm:gap-3 items-center',
)}>
{children}
</div>
</div>
</div>;
}
function EditButton ({
href,
label = 'Edit',
}: {
href: string,
label?: string,
}) {
return (
<Link
title="Edit"
href={href}
className="button"
>
<FaRegEdit className="translate-y-[-0.5px]" />
<span className="hidden sm:inline-block">
{label}
</span>
</Link>
);
}
function DeleteButton () {
return <SubmitButtonWithStatus
title="Delete"
icon={<span className="inline-flex text-[18px]">×</span>}
>
Delete
</SubmitButtonWithStatus>;
}
function BlobUrls ({
blobUrls,
label,

View File

@ -0,0 +1,49 @@
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/cache';
import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS } from '@/site/paths';
import { getPhotosTagCount } from '@/services/postgres';
import PhotoTag from '@/tag/PhotoTag';
import { photoQuantityText } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
export const runtime = 'edge';
interface Props {
params: { tag: string }
}
export default async function PhotoPageEdit({ params: { tag } }: Props) {
const [
count,
photos,
] = await Promise.all([
getPhotosTagCount(tag),
getPhotosCached({ tag }),
]);
if (count === 0) { redirect(PATH_ADMIN); }
return (
<AdminChildPage
backPath={PATH_ADMIN_TAGS}
backLabel="Tags"
breadcrumb={<div className="flex item gap-2">
<PhotoTag {...{ tag }} />
<div className="text-dim uppercase">
{photoQuantityText(count, false)}
</div>
</div>}
>
<div className="space-y-8">
<PhotoGrid
photos={photos.slice(0, 12)}
animate={false}
small
/>
<TagForm {...{ tag, photos }} />
</div>
</AdminChildPage>
);
};

View File

@ -0,0 +1,49 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import SiteGrid from '@/components/SiteGrid';
import { deletePhotoTagGloballyAction } from '@/photo/actions';
import AdminGrid from '@/admin/AdminGrid';
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';
import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/site/paths';
export const runtime = 'edge';
export default async function AdminPhotosPage() {
const tags = await getUniqueTagsWithCountCached();
return (
<SiteGrid
contentMain={
<div className="space-y-6">
<div className="space-y-4">
<AdminGrid>
{tags.map(({ tag, count }) =>
<Fragment key={tag}>
<div className="pr-2">
<PhotoTag {...{ tag }} />
</div>
<div className="text-dim uppercase">
{photoQuantityText(count, false)}
</div>
<EditButton href={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatTag(tag)}?" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton />
</FormWithConfirm>
</Fragment>)}
</AdminGrid>
</div>
</div>}
/>
);
}

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

10
src/cache/index.ts vendored
View File

@ -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),

View File

@ -1,22 +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 (
<div className="space-y-5">
<Link href="/admin/photos" className="flex gap-1 items-center">
<FiArrowLeft size={16} />
Admin
</Link>
<div>
{children}
</div>
</div>
<SiteGrid
contentMain={
<div className="space-y-6">
{backPath &&
<div className="flex gap-3 items-center h-9">
<Link
href={backPath}
className="flex gap-1.5 items-center"
>
<FiArrowLeft size={16} />
{backLabel || 'Back'}
</Link>
{breadcrumb &&
<>
<span>/</span>
<span className={cc(
'py-0.5 px-2 rounded-md bg-gray-100 dark:bg-gray-900',
'border border-gray-200 dark:border-gray-800'
)}>
{breadcrumb}
</span>
</>}
</div>}
<div>
{children}
</div>
</div>}
/>
);
};

View File

@ -25,7 +25,7 @@ export default function FooterAuth() {
contentMain={<div className={cc(
'flex items-center',
'my-8',
'text-gray-400 dark:text-gray-500',
'text-dim',
)}>
<div className="flex gap-x-4 gap-y-1 flex-wrap flex-grow">
{hasState

View File

@ -18,7 +18,7 @@ export default function FooterStatic({
contentMain={<div className={cc(
'my-8',
'flex items-center',
'text-gray-400 dark:text-gray-500',
'text-dim',
)}>
<div className="flex gap-x-4 gap-y-1 flex-grow flex-wrap">
<Link

View File

@ -27,7 +27,7 @@ export default function HeaderList({
{title}
</div>,
].concat(items)}
classNameItem="text-gray-400 dark:text-gray-500"
classNameItem="text-dim"
/>
);
}

View File

@ -16,7 +16,7 @@ export default function ShareButton({
<IconPathButton {...{
path,
icon: <TbPhotoShare size={17} className={dim
? 'text-gray-400 dark:text-gray-500'
? 'text-dim'
: undefined} />,
prefetch,
shouldScroll,

View File

@ -27,7 +27,7 @@ export default function StatusIcon({
case 'optional':
return <BiSolidCheckboxMinus
size={18}
className="text-gray-400 dark:text-gray-500"
className="text-dim"
/>;
}
};

View File

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

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

@ -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 (
<div className="space-y-4">
<AnimateItems
className={cc(
'grid gap-1',
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
small
? 'grid-cols-4 xs:grid-cols-6'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',
)}
type={animate === false ? 'none' : undefined}

View File

@ -35,7 +35,7 @@ export default function PhotoHeader({
{entity}
<span className={cc(
'inline-flex gap-2 items-center self-start',
'uppercase text-gray-400 dark:text-gray-500',
'uppercase text-dim',
'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}>
{selectedPhotoIndex !== undefined
@ -48,7 +48,7 @@ export default function PhotoHeader({
<span className={cc(
'hidden sm:inline-block',
'text-right uppercase',
'text-gray-400 dark:text-gray-500',
'text-dim',
)}>
{start === end
? start

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ||

View File

@ -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({
)}
/>}
<span className="uppercase">
{tag.replaceAll('-', ' ')}
{formatTag(tag)}
</span>
</Link>
);

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

@ -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 (
<form
action={renamePhotoTagGloballyAction}
className="space-y-8"
>
<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>
);
}

View File

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

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