Add admin sub-nav
This commit is contained in:
parent
d30c8a14de
commit
c9599120d2
40
src/admin/AdminNav.tsx
Normal file
40
src/admin/AdminNav.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
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 (
|
||||
<div className={cc(
|
||||
'border-b border-gray-900 pb-2',
|
||||
)}>
|
||||
<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) && 'text-dim',
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span>({count})</span>
|
||||
</Link>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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-6">
|
||||
<AdminNav items={navItems} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -58,93 +58,91 @@ 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 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"
|
||||
>
|
||||
<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={pathForPhotoEdit(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>}
|
||||
/>
|
||||
|
||||
@ -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"
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,10 +19,11 @@ 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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user