Merge pull request #2 from sambecker/next
Add global tag management to admin panel
This commit is contained in:
commit
1fd41462e7
@ -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}`;
|
||||
|
||||
@ -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
34
pnpm-lock.yaml
generated
@ -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
26
src/admin/AdminGrid.tsx
Normal 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
45
src/admin/AdminNav.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
src/admin/DeleteButton.tsx
Normal file
10
src/admin/DeleteButton.tsx
Normal 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
23
src/admin/EditButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/app/(auth-state)/admin/layout.tsx
Normal file
43
src/app/(auth-state)/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
@ -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,
|
||||
|
||||
49
src/app/(auth-state)/admin/tags/[tag]/edit/page.tsx
Normal file
49
src/app/(auth-state)/admin/tags/[tag]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/app/(auth-state)/admin/tags/page.tsx
Normal file
49
src/app/(auth-state)/admin/tags/page.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
10
src/cache/index.ts
vendored
@ -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),
|
||||
|
||||
@ -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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -27,7 +27,7 @@ export default function HeaderList({
|
||||
{title}
|
||||
</div>,
|
||||
].concat(items)}
|
||||
classNameItem="text-gray-400 dark:text-gray-500"
|
||||
classNameItem="text-dim"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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
67
src/tag/TagForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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(' ');
|
||||
|
||||
|
||||
@ -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