Merge pull request #94 from sambecker/private
Make hidden photos available for admin viewing
This commit is contained in:
commit
b823180976
@ -213,6 +213,9 @@ FAQ
|
||||
#### Why do my vertical images take up so much space?
|
||||
> By default, all photos are shown full-width, regardless of orientation. Enable matting to showcase horizontal and vertical photos at a similar scale by setting `NEXT_PUBLIC_MATTE_PHOTOS = 1`.
|
||||
|
||||
#### How secure are photos marked “hidden?”
|
||||
> While all hidden paths (`/tag/hidden/*`) require authentication, raw links to individual photo files remain publicly accessible. Their randomly generated file names are only secure via obscurity.
|
||||
|
||||
#### My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?
|
||||
> Navigate to `/admin/configuration` and click "Clear Cache."
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-len */
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
getEscapePath,
|
||||
getPathComponents,
|
||||
@ -13,11 +12,13 @@ import {
|
||||
isPathFilmSimulationShare,
|
||||
isPathPhoto,
|
||||
isPathPhotoShare,
|
||||
isPathProtected,
|
||||
isPathTag,
|
||||
isPathTagPhoto,
|
||||
isPathTagPhotoShare,
|
||||
isPathTagShare,
|
||||
} from '@/site/paths';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
|
||||
const PHOTO_ID = 'UsKSGcbt';
|
||||
const TAG = 'tag-name';
|
||||
@ -39,6 +40,11 @@ const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`;
|
||||
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
|
||||
const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`;
|
||||
|
||||
const PATH_TAG_HIDDEN = `/tag/${TAG_HIDDEN}`;
|
||||
const PATH_TAG_HIDDEN_SHARE = `${PATH_TAG_HIDDEN}/${SHARE}`;
|
||||
const PATH_TAG_HIDDEN_PHOTO = `${PATH_TAG_HIDDEN}/${PHOTO_ID}`;
|
||||
const PATH_TAG_HIDDEN_PHOTO_SHARE = `${PATH_TAG_HIDDEN_PHOTO}/${SHARE}`;
|
||||
|
||||
const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`;
|
||||
const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`;
|
||||
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`;
|
||||
@ -50,6 +56,21 @@ const PATH_FILM_SIMULATION_PHOTO = `${PATH_FILM_SIMULATION}/${PHOTO_ID}`;
|
||||
const PATH_FILM_SIMULATION_PHOTO_SHARE = `${PATH_FILM_SIMULATION_PHOTO}/${SHARE}`;
|
||||
|
||||
describe('Paths', () => {
|
||||
it('can be protected', () => {
|
||||
// Public
|
||||
expect(isPathProtected(PATH_ROOT)).toBe(false);
|
||||
expect(isPathProtected(PATH_PHOTO)).toBe(false);
|
||||
expect(isPathProtected(PATH_TAG)).toBe(false);
|
||||
expect(isPathProtected(PATH_TAG_PHOTO)).toBe(false);
|
||||
expect(isPathProtected(PATH_CAMERA)).toBe(false);
|
||||
expect(isPathProtected(PATH_FILM_SIMULATION)).toBe(false);
|
||||
// Private
|
||||
expect(isPathProtected(PATH_ADMIN)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN_SHARE)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO_SHARE)).toBe(true);
|
||||
});
|
||||
it('can be classified', () => {
|
||||
// Positive
|
||||
expect(isPathPhoto(PATH_PHOTO)).toBe(true);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "jest --watch",
|
||||
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
|
||||
"analyze": "ANALYZE=true next build"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import InfoBlock from '@/components/InfoBlock';
|
||||
import Banner from '@/components/Banner';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import {
|
||||
PATH_ADMIN_CONFIGURATION,
|
||||
@ -96,13 +96,10 @@ export default function AdminNavClient({
|
||||
</Link>
|
||||
</div>
|
||||
{shouldShowBanner &&
|
||||
<InfoBlock centered={false} padding="tight" color="blue">
|
||||
<div className="flex items-center gap-3">
|
||||
<FaRegClock className="flex-shrink-0" />
|
||||
Photo updates detected—they may take several minutes to show up
|
||||
for visitors
|
||||
</div>
|
||||
</InfoBlock>}
|
||||
<Banner icon={<FaRegClock className="flex-shrink-0" />}>
|
||||
Photo updates detected—they may take several minutes to show upe
|
||||
for visitors
|
||||
</Banner>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -50,15 +50,16 @@ export default function AdminPhotosTable({
|
||||
prefetch={false}
|
||||
>
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-2',
|
||||
photo.hidden && 'text-dim',
|
||||
)}>
|
||||
<span>{titleForPhoto(photo)}</span>
|
||||
{photo.hidden &&
|
||||
{titleForPhoto(photo)}
|
||||
{photo.hidden && <span className="whitespace-nowrap">
|
||||
{' '}
|
||||
<AiOutlineEyeInvisible
|
||||
className="translate-y-[0.25px]"
|
||||
className="inline translate-y-[-0.5px]"
|
||||
size={16}
|
||||
/>}
|
||||
/>
|
||||
</span>}
|
||||
</span>
|
||||
{photo.priorityOrder !== null &&
|
||||
<span className={clsx(
|
||||
|
||||
@ -11,7 +11,7 @@ export default async function PhotoEditPage({
|
||||
}: {
|
||||
params: { photoId: string }
|
||||
}) {
|
||||
const photo = await getPhotoNoStore(photoId);
|
||||
const photo = await getPhotoNoStore(photoId, true);
|
||||
|
||||
if (!photo) { redirect(PATH_ADMIN); }
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ export default async function AdminPhotosPage() {
|
||||
blobPhotoUrls,
|
||||
] = await Promise.all([
|
||||
getPhotos({
|
||||
includeHidden: true,
|
||||
hidden: 'include',
|
||||
sortBy: 'createdAt',
|
||||
limit: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
|
||||
}).catch(() => []),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
|
||||
import { PaginationParams } from '@/site/pagination';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
import { generateMetaForTag } from '@/tag';
|
||||
import TagOverview from '@/tag/TagOverview';
|
||||
import {
|
||||
@ -7,6 +8,7 @@ import {
|
||||
getPhotosTagDataCachedWithPagination,
|
||||
} from '@/tag/data';
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface TagProps {
|
||||
params: { tag: string }
|
||||
@ -25,6 +27,8 @@ export async function generateMetadata({
|
||||
limit: GRID_THUMBNAILS_TO_SHOW_MAX,
|
||||
});
|
||||
|
||||
if (photos.length === 0) { return {}; }
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
@ -65,6 +69,8 @@ export default async function TagPage({
|
||||
searchParams,
|
||||
});
|
||||
|
||||
if (photos.length === 0) { redirect(PATH_ROOT); }
|
||||
|
||||
return (
|
||||
<TagOverview {...{ tag, photos, count, dateRange, showMorePath }} />
|
||||
);
|
||||
|
||||
56
src/app/tag/hidden/[photoId]/page.tsx
Normal file
56
src/app/tag/hidden/[photoId]/page.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { descriptionForPhoto, titleForPhoto } from '@/photo';
|
||||
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||
import { getPhotoCached, getPhotosCached } from '@/photo/cache';
|
||||
import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { cache } from 'react';
|
||||
|
||||
const getPhotoCachedCached = cache(getPhotoCached);
|
||||
|
||||
interface PhotoTagProps {
|
||||
params: { photoId: string }
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { photoId },
|
||||
}: PhotoTagProps): Promise<Metadata> {
|
||||
const photo = await getPhotoCachedCached(photoId, true);
|
||||
|
||||
if (!photo) { return {}; }
|
||||
|
||||
const title = titleForPhoto(photo);
|
||||
const description = descriptionForPhoto(photo);
|
||||
const url = absolutePathForPhoto(photo, TAG_HIDDEN);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoTagHiddenPage({
|
||||
params: { photoId },
|
||||
}: PhotoTagProps) {
|
||||
const photo = await getPhotoCachedCached(photoId, true);
|
||||
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const photos = await getPhotosCached({ hidden: 'only' });
|
||||
const count = photos.length;
|
||||
|
||||
return (
|
||||
<PhotoDetailPage {...{ photo, photos, count, tag: TAG_HIDDEN }} />
|
||||
);
|
||||
}
|
||||
71
src/app/tag/hidden/page.tsx
Normal file
71
src/app/tag/hidden/page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import Banner from '@/components/Banner';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import PhotoGrid from '@/photo/PhotoGrid';
|
||||
import { getPhotosCached, getPhotosTagHiddenMetaCached } from '@/photo/cache';
|
||||
import { absolutePathForTag } from '@/site/paths';
|
||||
import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag';
|
||||
import HiddenHeader from '@/tag/HiddenHeader';
|
||||
import { Metadata } from 'next';
|
||||
import { cache } from 'react';
|
||||
|
||||
const getPhotosTagHiddenMetaCachedCached = cache(getPhotosTagHiddenMetaCached);
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { count, dateRange } = await getPhotosTagHiddenMetaCachedCached();
|
||||
|
||||
if (count === 0) { return {}; }
|
||||
|
||||
const title = titleForTag(TAG_HIDDEN, undefined, count);
|
||||
const description = descriptionForTaggedPhotos(
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
dateRange,
|
||||
);
|
||||
const url = absolutePathForTag(TAG_HIDDEN);
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HiddenTagPage() {
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ hidden: 'only' }),
|
||||
getPhotosTagHiddenMetaCached(),
|
||||
]);
|
||||
return (
|
||||
<SiteGrid
|
||||
contentMain={<div className="space-y-4 mt-4">
|
||||
<AnimateItems
|
||||
type="bottom"
|
||||
items={[<HiddenHeader
|
||||
key="HiddenHeader"
|
||||
{...{ photos, count, dateRange }}
|
||||
/>]}
|
||||
animateOnFirstLoadOnly
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<Banner animate>
|
||||
Only visible to authenticated admins
|
||||
</Banner>
|
||||
<PhotoGrid {...{ photos }} />
|
||||
</div>
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/components/Banner.tsx
Normal file
40
src/components/Banner.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { ReactNode } from 'react';
|
||||
import InfoBlock from './InfoBlock';
|
||||
import AnimateItems from './AnimateItems';
|
||||
import { IoInformationCircleOutline } from 'react-icons/io5';
|
||||
|
||||
export default function Banner({
|
||||
icon,
|
||||
children,
|
||||
animate,
|
||||
className,
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
animate?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<AnimateItems
|
||||
type={animate ? 'bottom' : 'none'}
|
||||
items={[
|
||||
<InfoBlock
|
||||
key="Banner"
|
||||
className={className}
|
||||
centered={false}
|
||||
padding="tight"
|
||||
color="blue"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon ?? <IoInformationCircleOutline
|
||||
size={17}
|
||||
className="translate-y-[1px]"
|
||||
/>}
|
||||
{children}
|
||||
</div>
|
||||
</InfoBlock>,
|
||||
]}
|
||||
animateOnFirstLoadOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,7 @@ import {
|
||||
PATH_ADMIN_UPLOADS,
|
||||
PATH_SIGN_IN,
|
||||
pathForPhoto,
|
||||
pathForTag,
|
||||
} from '../site/paths';
|
||||
import Modal from './Modal';
|
||||
import { clsx } from 'clsx/lite';
|
||||
@ -37,6 +38,9 @@ import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
|
||||
import PhotoDate from '@/photo/PhotoDate';
|
||||
import PhotoTiny from '@/photo/PhotoTiny';
|
||||
import { FaCheck } from 'react-icons/fa6';
|
||||
import { TagsWithMeta, addHiddenToTags } from '@/tag';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
|
||||
const LISTENER_KEYDOWN = 'keydown';
|
||||
const MINIMUM_QUERY_LENGTH = 2;
|
||||
@ -44,9 +48,9 @@ const MINIMUM_QUERY_LENGTH = 2;
|
||||
type CommandKItem = {
|
||||
label: string
|
||||
keywords?: string[]
|
||||
accessory?: ReactNode
|
||||
annotation?: ReactNode
|
||||
annotationAria?: string
|
||||
accessory?: ReactNode
|
||||
path?: string
|
||||
action?: () => void | Promise<void>
|
||||
}
|
||||
@ -58,10 +62,12 @@ export type CommandKSection = {
|
||||
}
|
||||
|
||||
export default function CommandKClient({
|
||||
tags,
|
||||
serverSections = [],
|
||||
showDebugTools,
|
||||
footer,
|
||||
}: {
|
||||
tags: TagsWithMeta
|
||||
serverSections?: CommandKSection[]
|
||||
showDebugTools?: boolean
|
||||
footer?: string
|
||||
@ -70,6 +76,7 @@ export default function CommandKClient({
|
||||
isUserSignedIn,
|
||||
setUserEmail,
|
||||
isCommandKOpen: isOpen,
|
||||
hiddenPhotosCount,
|
||||
arePhotosMatted,
|
||||
shouldShowBaselineGrid,
|
||||
shouldDebugBlur,
|
||||
@ -173,6 +180,24 @@ export default function CommandKClient({
|
||||
}
|
||||
}, [isOpen, setShouldRespondToKeyboardCommands]);
|
||||
|
||||
const tagsIncludingHidden = useMemo(() =>
|
||||
addHiddenToTags(tags, hiddenPhotosCount)
|
||||
, [tags, hiddenPhotosCount]);
|
||||
|
||||
const SECTION_TAGS: CommandKSection = {
|
||||
heading: 'Tags',
|
||||
accessory: <FaTag
|
||||
size={10}
|
||||
className="translate-x-[1px] translate-y-[0.75px]"
|
||||
/>,
|
||||
items: tagsIncludingHidden.map(({ tag, count }) => ({
|
||||
label: tag,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForTag(tag),
|
||||
})),
|
||||
};
|
||||
|
||||
const clientSections: CommandKSection[] = [{
|
||||
heading: 'Theme',
|
||||
accessory: <IoInvertModeSharp
|
||||
@ -316,6 +341,7 @@ export default function CommandKClient({
|
||||
{isLoading ? 'Searching ...' : 'No results found'}
|
||||
</Command.Empty>
|
||||
{queriedSections
|
||||
.concat(SECTION_TAGS)
|
||||
.concat(serverSections)
|
||||
.concat(sectionPages)
|
||||
.concat(adminSection)
|
||||
|
||||
@ -59,7 +59,7 @@ export default function FieldSetWithStatus({
|
||||
<span className="text-gray-400 dark:text-gray-600">
|
||||
({note})
|
||||
</span>}
|
||||
{isModified &&
|
||||
{isModified && !error &&
|
||||
<span className={clsx(
|
||||
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
|
||||
)}>
|
||||
|
||||
@ -58,12 +58,12 @@ export default function InfinitePhotoScroll({
|
||||
? getPhotosCachedAction(
|
||||
initialOffset + size * itemsPerPage,
|
||||
itemsPerPage,
|
||||
includeHiddenPhotos,
|
||||
includeHiddenPhotos ? 'include' : 'exclude',
|
||||
)
|
||||
: getPhotosAction(
|
||||
initialOffset + size * itemsPerPage,
|
||||
itemsPerPage,
|
||||
includeHiddenPhotos,
|
||||
includeHiddenPhotos ? 'include' : 'exclude',
|
||||
)
|
||||
, [useCachedPhotos, initialOffset, itemsPerPage, includeHiddenPhotos]);
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import { Camera } from '@/camera';
|
||||
import CameraHeader from '@/camera/CameraHeader';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import FilmSimulationHeader from '@/simulation/FilmSimulationHeader';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import HiddenHeader from '@/tag/HiddenHeader';
|
||||
|
||||
export default function PhotoDetailPage({
|
||||
photo,
|
||||
@ -35,8 +37,13 @@ export default function PhotoDetailPage({
|
||||
{tag &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={
|
||||
<TagHeader
|
||||
contentMain={tag === TAG_HIDDEN
|
||||
? <HiddenHeader
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
count={count ?? 0}
|
||||
/>
|
||||
: <TagHeader
|
||||
key={tag}
|
||||
tag={tag}
|
||||
photos={photos}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Cameras, sortCamerasWithCount } from '@/camera';
|
||||
import PhotoCamera from '@/camera/PhotoCamera';
|
||||
import HeaderList from '@/components/HeaderList';
|
||||
@ -5,11 +7,14 @@ import PhotoTag from '@/tag/PhotoTag';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
|
||||
import { TAG_FAVS, TagsWithMeta } from '@/tag';
|
||||
import { TAG_FAVS, TAG_HIDDEN, TagsWithMeta, addHiddenToTags } from '@/tag';
|
||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation';
|
||||
import FavsTag from '../tag/FavsTag';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useMemo } from 'react';
|
||||
import HiddenTag from '@/tag/HiddenTag';
|
||||
|
||||
export default function PhotoGridSidebar({
|
||||
tags,
|
||||
@ -26,29 +31,49 @@ export default function PhotoGridSidebar({
|
||||
}) {
|
||||
const { start, end } = dateRangeForPhotos(undefined, photosDateRange);
|
||||
|
||||
const { hiddenPhotosCount } = useAppState();
|
||||
|
||||
const tagsIncludingHidden = useMemo(() =>
|
||||
addHiddenToTags(tags, hiddenPhotosCount)
|
||||
, [tags, hiddenPhotosCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tags.length > 0 && <HeaderList
|
||||
title='Tags'
|
||||
icon={<FaTag size={12} className="text-icon" />}
|
||||
items={tags.map(({ tag, count }) => tag === TAG_FAVS
|
||||
? <FavsTag
|
||||
key={TAG_FAVS}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
prefetch={false}
|
||||
contrast="low"
|
||||
badged
|
||||
/>
|
||||
: <PhotoTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
type="text-only"
|
||||
countOnHover={count}
|
||||
prefetch={false}
|
||||
contrast="low"
|
||||
badged
|
||||
/>)}
|
||||
items={tagsIncludingHidden.map(({ tag, count }) => {
|
||||
switch (tag) {
|
||||
case TAG_FAVS:
|
||||
return <FavsTag
|
||||
key={TAG_FAVS}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
prefetch={false}
|
||||
contrast="low"
|
||||
badged
|
||||
/>;
|
||||
case TAG_HIDDEN:
|
||||
return <HiddenTag
|
||||
key={TAG_HIDDEN}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
prefetch={false}
|
||||
contrast="low"
|
||||
badged
|
||||
/>;
|
||||
default:
|
||||
return <PhotoTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
type="text-only"
|
||||
countOnHover={count}
|
||||
prefetch={false}
|
||||
contrast="low"
|
||||
badged
|
||||
/>;
|
||||
}
|
||||
})}
|
||||
/>}
|
||||
{cameras.length > 0 && <HeaderList
|
||||
title="Cameras"
|
||||
|
||||
@ -17,11 +17,11 @@ export default function PhotoSetHeader({
|
||||
dateRange,
|
||||
}: {
|
||||
entity: ReactNode
|
||||
entityVerb: string
|
||||
entityVerb?: string
|
||||
entityDescription: string
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
sharePath: string
|
||||
sharePath?: string
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
@ -45,7 +45,7 @@ export default function PhotoSetHeader({
|
||||
: 'xs:grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
)}>
|
||||
<span className={clsx(
|
||||
'inline-flex',
|
||||
'inline-flex uppercase',
|
||||
HIGH_DENSITY_GRID && 'sm:col-span-2',
|
||||
)}>
|
||||
{entity}
|
||||
@ -59,9 +59,9 @@ export default function PhotoSetHeader({
|
||||
)}>
|
||||
{selectedPhotoIndex !== undefined
|
||||
// eslint-disable-next-line max-len
|
||||
? `${entityVerb} ${selectedPhotoIndex + 1} of ${count ?? photos.length}`
|
||||
? `${entityVerb ? `${entityVerb} ` : ''}${selectedPhotoIndex + 1} of ${count ?? photos.length}`
|
||||
: entityDescription}
|
||||
{selectedPhotoIndex === undefined &&
|
||||
{selectedPhotoIndex === undefined && sharePath &&
|
||||
<ShareButton
|
||||
className="translate-y-[1.5px]"
|
||||
path={sharePath}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
GetPhotosOptions,
|
||||
sqlDeletePhoto,
|
||||
sqlInsertPhoto,
|
||||
sqlDeletePhotoTagGlobally,
|
||||
@ -21,6 +22,7 @@ import {
|
||||
} from '@/services/storage';
|
||||
import {
|
||||
getPhotosCachedCached,
|
||||
getPhotosTagHiddenMetaCached,
|
||||
revalidateAdminPaths,
|
||||
revalidateAllKeysAndPaths,
|
||||
revalidatePhoto,
|
||||
@ -47,7 +49,7 @@ export const createPhotoAction = async (formData: FormData) =>
|
||||
safelyRunAdminServerAction(async () => {
|
||||
const photo = convertFormDataToPhotoDbInsert(formData, true);
|
||||
|
||||
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
|
||||
const updatedUrl = await convertUploadToPhoto(photo.url);
|
||||
|
||||
if (updatedUrl) { photo.url = updatedUrl; }
|
||||
|
||||
@ -62,6 +64,14 @@ export const updatePhotoAction = async (formData: FormData) =>
|
||||
safelyRunAdminServerAction(async () => {
|
||||
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||
|
||||
let url: string | undefined;
|
||||
if (photo.hidden && photo.url.includes(photo.id)) {
|
||||
// Anonymize storage url on update if necessary by
|
||||
// re-running image upload transfer logic
|
||||
url = await convertUploadToPhoto(photo.url);
|
||||
if (url) { photo.url = url; }
|
||||
}
|
||||
|
||||
await sqlUpdatePhoto(photo);
|
||||
|
||||
revalidatePhoto(photo.id);
|
||||
@ -196,21 +206,30 @@ export const streamAiImageQueryAction = async (
|
||||
export const getImageBlurAction = async (url: string) =>
|
||||
safelyRunAdminServerAction(() => blurImageFromUrl(url));
|
||||
|
||||
// Public actions
|
||||
export const getPhotosTagHiddenMetaCachedAction = async () =>
|
||||
safelyRunAdminServerAction(getPhotosTagHiddenMetaCached);
|
||||
|
||||
// Public/Private actions
|
||||
|
||||
export const getPhotosAction = async (
|
||||
offset: number,
|
||||
limit: number,
|
||||
includeHidden?: boolean,
|
||||
) =>
|
||||
getPhotos({ offset, includeHidden, limit });
|
||||
hidden?: GetPhotosOptions['hidden'],
|
||||
) => (hidden === 'include' || hidden === 'only')
|
||||
? safelyRunAdminServerAction(() =>
|
||||
getPhotos({ offset, hidden, limit }))
|
||||
: getPhotos({ offset, hidden, limit });
|
||||
|
||||
export const getPhotosCachedAction = async (
|
||||
offset: number,
|
||||
limit: number,
|
||||
includeHidden?: boolean,
|
||||
) =>
|
||||
getPhotosCachedCached({ offset, includeHidden, limit });
|
||||
hidden?: GetPhotosOptions['hidden'],
|
||||
) => (hidden === 'include' || hidden === 'only')
|
||||
? safelyRunAdminServerAction(() =>
|
||||
getPhotosCachedCached({ offset, hidden, limit }))
|
||||
: getPhotosCachedCached({ offset, hidden, limit });
|
||||
|
||||
// Public actions
|
||||
|
||||
export const queryPhotosByTitleAction = async (query: string) =>
|
||||
(await getPhotos({ query, limit: 10 }))
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
getPhotosDateRange,
|
||||
getPhotosNearId,
|
||||
getPhotosMostRecentUpdate,
|
||||
getPhotosTagHiddenMeta,
|
||||
} from '@/photo/db';
|
||||
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
||||
import { createCameraKey } from '@/camera';
|
||||
@ -179,6 +180,12 @@ export const getPhotosTagMetaCached =
|
||||
[KEY_PHOTOS, KEY_TAGS, KEY_DATE_RANGE],
|
||||
);
|
||||
|
||||
export const getPhotosTagHiddenMetaCached =
|
||||
unstable_cache(
|
||||
getPhotosTagHiddenMeta,
|
||||
[KEY_PHOTOS, KEY_TAGS, KEY_HIDDEN, KEY_DATE_RANGE],
|
||||
);
|
||||
|
||||
export const getPhotosCameraMetaCached =
|
||||
unstable_cache(
|
||||
getPhotosCameraMeta,
|
||||
|
||||
@ -173,11 +173,10 @@ export const sqlDeletePhoto = (id: string) =>
|
||||
'sqlDeletePhoto',
|
||||
);
|
||||
|
||||
const sqlGetPhoto = (id: string) =>
|
||||
safelyQueryPhotos(
|
||||
() => sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`,
|
||||
'sqlGetPhoto',
|
||||
);
|
||||
const sqlGetPhoto = (id: string, includeHidden?: boolean) => includeHidden
|
||||
? sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
|
||||
// eslint-disable-next-line max-len
|
||||
: sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} AND hidden IS NOT TRUE LIMIT 1`;
|
||||
|
||||
const sqlGetPhotosCount = async () => sql`
|
||||
SELECT COUNT(*) FROM photos
|
||||
@ -212,6 +211,17 @@ const sqlGetPhotosTagMeta = async (tag: string) => sql`
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const sqlGetPhotosTagHiddenMeta = async () => sql`
|
||||
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
|
||||
FROM photos
|
||||
WHERE hidden IS TRUE
|
||||
`.then(({ rows }) => ({
|
||||
count: parseInt(rows[0].count, 10),
|
||||
...rows[0]?.start && rows[0]?.end
|
||||
? { dateRange: rows[0] as PhotoDateRange }
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const sqlGetPhotosCameraMeta = async (camera: Camera) => sql`
|
||||
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
|
||||
FROM photos
|
||||
@ -297,7 +307,7 @@ export type GetPhotosOptions = {
|
||||
simulation?: FilmSimulation
|
||||
takenBefore?: Date
|
||||
takenAfterInclusive?: Date
|
||||
includeHidden?: boolean
|
||||
hidden?: 'exclude' | 'include' | 'only'
|
||||
}
|
||||
|
||||
const safelyQueryPhotos = async <T>(
|
||||
@ -359,7 +369,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
simulation,
|
||||
takenBefore,
|
||||
takenAfterInclusive,
|
||||
includeHidden,
|
||||
hidden = 'exclude',
|
||||
} = options;
|
||||
|
||||
let sql = ['SELECT * FROM photos'];
|
||||
@ -368,8 +378,14 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
|
||||
// WHERE
|
||||
let wheres = [] as string[];
|
||||
if (!includeHidden) {
|
||||
|
||||
switch (hidden) {
|
||||
case 'exclude':
|
||||
wheres.push('hidden IS NOT TRUE');
|
||||
break;
|
||||
case 'only':
|
||||
wheres.push('hidden IS TRUE');
|
||||
break;
|
||||
}
|
||||
if (takenBefore) {
|
||||
wheres.push(`taken_at > $${valueIndex++}`);
|
||||
@ -428,6 +444,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
export const getPhotosNearId = async (
|
||||
id: string,
|
||||
limit: number,
|
||||
onlyHidden?: boolean,
|
||||
) => {
|
||||
const orderBy = PRIORITY_ORDER_ENABLED
|
||||
? 'ORDER BY priority_order ASC, taken_at DESC'
|
||||
@ -440,7 +457,7 @@ export const getPhotosNearId = async (
|
||||
SELECT *, row_number()
|
||||
OVER (${orderBy}) as row_number
|
||||
FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
WHERE hidden is ${onlyHidden ? 'TRUE' : 'NOT TRUE'}
|
||||
),
|
||||
current AS (SELECT row_number FROM twi WHERE id = $1)
|
||||
SELECT twi.*
|
||||
@ -468,11 +485,15 @@ export const getPhotoIds = async ({ limit }: { limit?: number }) => {
|
||||
.then(({ rows }) => rows.map(({ id }) => id as string));
|
||||
};
|
||||
|
||||
export const getPhoto = async (id: string): Promise<Photo | undefined> => {
|
||||
export const getPhoto = async (
|
||||
id: string,
|
||||
includeHidden?: boolean,
|
||||
): Promise<Photo | undefined> => {
|
||||
// Check for photo id forwarding
|
||||
// and convert short ids to uuids
|
||||
const photoId = translatePhotoId(id);
|
||||
return safelyQueryPhotos(() => sqlGetPhoto(photoId), 'getPhoto')
|
||||
return safelyQueryPhotos(() =>
|
||||
sqlGetPhoto(photoId, includeHidden), 'sqlGetPhoto')
|
||||
.then(({ rows }) => rows.map(parsePhotoFromDb))
|
||||
.then(photos => photos.length > 0 ? photos[0] : undefined);
|
||||
};
|
||||
@ -497,10 +518,9 @@ export const getUniqueTags = () =>
|
||||
export const getUniqueTagsHidden = () =>
|
||||
safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden');
|
||||
export const getPhotosTagMeta = (tag: string) =>
|
||||
safelyQueryPhotos(
|
||||
() => sqlGetPhotosTagMeta(tag),
|
||||
'getPhotosTagMeta',
|
||||
);
|
||||
safelyQueryPhotos(() => sqlGetPhotosTagMeta(tag), 'getPhotosTagMeta');
|
||||
export const getPhotosTagHiddenMeta = () =>
|
||||
safelyQueryPhotos(sqlGetPhotosTagHiddenMeta, 'sqlGetPhotosTagHiddenMeta');
|
||||
|
||||
// CAMERAS
|
||||
export const getUniqueCameras = () =>
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
} from '@/vendors/fujifilm';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { GEO_PRIVACY_ENABLED } from '@/site/config';
|
||||
import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag';
|
||||
import { TAG_FAVS, TAG_HIDDEN, doesStringContainReservedTags } from '@/tag';
|
||||
|
||||
type VirtualFields = 'favorite';
|
||||
|
||||
@ -76,8 +76,8 @@ const FORM_METADATA = (
|
||||
tags: {
|
||||
label: 'tags',
|
||||
tagOptions,
|
||||
validate: tags => doesTagsStringIncludeFavs(tags)
|
||||
? `'${TAG_FAVS}' is a reserved tag`
|
||||
validate: tags => doesStringContainReservedTags(tags)
|
||||
? `Reserved tags (${TAG_FAVS}, ${TAG_HIDDEN})`
|
||||
: undefined,
|
||||
},
|
||||
semanticDescription: {
|
||||
@ -141,10 +141,9 @@ export const isFormValid = (formData: Partial<PhotoFormData>) =>
|
||||
FORM_METADATA_ENTRIES().every(
|
||||
([key, { required, validate, validateStringMaxLength }]) =>
|
||||
(!required || Boolean(formData[key])) &&
|
||||
(validate?.(formData[key]) === undefined) &&
|
||||
(!validate?.(formData[key])) &&
|
||||
// eslint-disable-next-line max-len
|
||||
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) &&
|
||||
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
|
||||
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength)
|
||||
);
|
||||
|
||||
export const formHasTextContent = ({
|
||||
|
||||
@ -201,7 +201,7 @@ export const deleteConfirmationTextForPhoto = (photo: Photo) =>
|
||||
export type PhotoDateRange = { start: string, end: string };
|
||||
|
||||
export const descriptionForPhotoSet = (
|
||||
photos:Photo[],
|
||||
photos:Photo[] = [],
|
||||
descriptor?: string,
|
||||
dateBased?: boolean,
|
||||
explicitCount?: number,
|
||||
|
||||
@ -129,53 +129,66 @@ export const uploadPhotoFromClient = async (
|
||||
? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true)
|
||||
: vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`);
|
||||
|
||||
export const convertUploadToPhoto = async (
|
||||
uploadUrl: string,
|
||||
photoId?: string,
|
||||
): Promise<string> => {
|
||||
const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`;
|
||||
const fileExtension = getExtensionFromStorageUrl(uploadUrl);
|
||||
const photoPath = `${fileName}.${fileExtension ?? 'jpg'}`;
|
||||
|
||||
const storageType = storageTypeFromUrl(uploadUrl);
|
||||
const moveFile = async (
|
||||
originUrl: string,
|
||||
destinationFileName: string,
|
||||
) => {
|
||||
const storageType = storageTypeFromUrl(originUrl);
|
||||
|
||||
let url: string | undefined;
|
||||
|
||||
// Copy file
|
||||
switch (storageType) {
|
||||
case 'vercel-blob':
|
||||
url = await vercelBlobCopy(uploadUrl, photoPath, photoId === undefined);
|
||||
url = await vercelBlobCopy(
|
||||
originUrl,
|
||||
destinationFileName,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case 'cloudflare-r2':
|
||||
url = await cloudflareR2Copy(
|
||||
getFileNameFromStorageUrl(uploadUrl),
|
||||
photoPath,
|
||||
photoId === undefined,
|
||||
getFileNameFromStorageUrl(originUrl),
|
||||
destinationFileName,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case 'aws-s3':
|
||||
url = await awsS3Copy(uploadUrl, photoPath, photoId === undefined);
|
||||
url = await awsS3Copy(
|
||||
originUrl,
|
||||
destinationFileName,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// If successful, delete original file
|
||||
if (url) {
|
||||
switch (storageType) {
|
||||
case 'vercel-blob':
|
||||
await vercelBlobDelete(uploadUrl);
|
||||
await vercelBlobDelete(originUrl);
|
||||
break;
|
||||
case 'cloudflare-r2':
|
||||
await cloudflareR2Delete(getFileNameFromStorageUrl(uploadUrl));
|
||||
await cloudflareR2Delete(getFileNameFromStorageUrl(originUrl));
|
||||
break;
|
||||
case 'aws-s3':
|
||||
await awsS3Delete(getFileNameFromStorageUrl(uploadUrl));
|
||||
await awsS3Delete(getFileNameFromStorageUrl(originUrl));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const convertUploadToPhoto = async (
|
||||
urlOrigin: string,
|
||||
): Promise<string> => {
|
||||
const fileName = `${PREFIX_PHOTO}-${generateStorageId()}`;
|
||||
const fileExtension = getExtensionFromStorageUrl(urlOrigin);
|
||||
const photoPath = `${fileName}.${fileExtension || 'jpg'}`;
|
||||
return moveFile(urlOrigin, photoPath);
|
||||
};
|
||||
|
||||
export const deleteStorageUrl = (url: string) => {
|
||||
switch (storageTypeFromUrl(url)) {
|
||||
case 'vercel-blob':
|
||||
|
||||
@ -8,14 +8,12 @@ import {
|
||||
import {
|
||||
pathForCamera,
|
||||
pathForFilmSimulation,
|
||||
pathForTag,
|
||||
} from './paths';
|
||||
import { formatCameraText } from '@/camera';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
import { sortTagsObject } from '@/tag';
|
||||
import { TagsWithMeta } from '@/tag';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { ADMIN_DEBUG_TOOLS_ENABLED } from './config';
|
||||
|
||||
@ -27,25 +25,11 @@ export default async function CommandK() {
|
||||
filmSimulations,
|
||||
] = await Promise.all([
|
||||
getPhotosCountCached().catch(() => 0),
|
||||
getUniqueTagsCached().catch(() => []),
|
||||
getUniqueTagsCached().catch(() => [] as TagsWithMeta),
|
||||
getUniqueCamerasCached().catch(() => []),
|
||||
getUniqueFilmSimulationsCached().catch(() => []),
|
||||
]);
|
||||
|
||||
const SECTION_TAGS: CommandKSection = {
|
||||
heading: 'Tags',
|
||||
accessory: <FaTag
|
||||
size={10}
|
||||
className="translate-x-[1px] translate-y-[0.75px]"
|
||||
/>,
|
||||
items: sortTagsObject(tags).map(({ tag, count }) => ({
|
||||
label: tag,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForTag(tag),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_CAMERAS: CommandKSection = {
|
||||
heading: 'Cameras',
|
||||
accessory: <IoMdCamera />,
|
||||
@ -71,8 +55,8 @@ export default async function CommandK() {
|
||||
};
|
||||
|
||||
return <CommandKClient
|
||||
tags={tags}
|
||||
serverSections={[
|
||||
SECTION_TAGS,
|
||||
SECTION_CAMERAS,
|
||||
SECTION_FILM,
|
||||
]}
|
||||
|
||||
@ -3,6 +3,7 @@ import { BASE_URL } from './config';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
|
||||
// Core paths
|
||||
export const PATH_ROOT = '/';
|
||||
@ -98,13 +99,15 @@ export const pathForPhoto = (
|
||||
camera?: Camera,
|
||||
simulation?: FilmSimulation,
|
||||
) =>
|
||||
tag
|
||||
? `${pathForTag(tag)}/${getPhotoId(photo)}`
|
||||
: camera
|
||||
? `${pathForCamera(camera)}/${getPhotoId(photo)}`
|
||||
: simulation
|
||||
? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}`
|
||||
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
|
||||
typeof photo !== 'string' && photo.hidden
|
||||
? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
|
||||
: tag
|
||||
? `${pathForTag(tag)}/${getPhotoId(photo)}`
|
||||
: camera
|
||||
? `${pathForCamera(camera)}/${getPhotoId(photo)}`
|
||||
: simulation
|
||||
? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}`
|
||||
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
|
||||
|
||||
export const pathForPhotoShare = (
|
||||
photo: PhotoOrPhotoId,
|
||||
@ -248,7 +251,8 @@ export const isPathAdminConfiguration = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION);
|
||||
|
||||
export const isPathProtected = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_ADMIN);
|
||||
checkPathPrefix(pathname, PATH_ADMIN) ||
|
||||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN));
|
||||
|
||||
export const getPathComponents = (pathname = ''): {
|
||||
photoId?: string
|
||||
|
||||
@ -2,29 +2,33 @@ import { Dispatch, SetStateAction, createContext, useContext } from 'react';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
|
||||
export interface AppStateContext {
|
||||
// CORE
|
||||
previousPathname?: string
|
||||
hasLoaded?: boolean
|
||||
arePhotosMatted?: boolean
|
||||
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
|
||||
setHasLoaded?: Dispatch<SetStateAction<boolean>>
|
||||
swrTimestamp?: number
|
||||
invalidateSwr?: () => void
|
||||
userEmail?: string
|
||||
setUserEmail?: Dispatch<SetStateAction<string | undefined>>
|
||||
isUserSignedIn?: boolean
|
||||
setHasLoaded?: Dispatch<SetStateAction<boolean>>
|
||||
nextPhotoAnimation?: AnimationConfig
|
||||
setNextPhotoAnimation?: Dispatch<SetStateAction<AnimationConfig | undefined>>
|
||||
clearNextPhotoAnimation?: () => void
|
||||
shouldRespondToKeyboardCommands?: boolean
|
||||
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
|
||||
isCommandKOpen?: boolean
|
||||
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
|
||||
// ADMIN
|
||||
userEmail?: string
|
||||
setUserEmail?: Dispatch<SetStateAction<string | undefined>>
|
||||
isUserSignedIn?: boolean
|
||||
adminUpdateTimes?: Date[]
|
||||
registerAdminUpdate?: () => void
|
||||
shouldShowBaselineGrid?: boolean
|
||||
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
|
||||
hiddenPhotosCount?: number
|
||||
// DEBUG
|
||||
arePhotosMatted?: boolean
|
||||
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugBlur?: boolean
|
||||
setShouldDebugBlur?: Dispatch<SetStateAction<boolean>>
|
||||
clearNextPhotoAnimation?: () => void
|
||||
shouldShowBaselineGrid?: boolean
|
||||
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const AppStateContext = createContext<AppStateContext>({});
|
||||
|
||||
@ -7,6 +7,7 @@ import usePathnames from '@/utility/usePathnames';
|
||||
import { getAuthAction, logClientAuthUpdate } from '@/auth/actions';
|
||||
import useSWR from 'swr';
|
||||
import { MATTE_PHOTOS } from '@/site/config';
|
||||
import { getPhotosTagHiddenMetaCachedAction } from '@/photo/actions';
|
||||
|
||||
export default function AppStateProvider({
|
||||
children,
|
||||
@ -15,25 +16,31 @@ export default function AppStateProvider({
|
||||
}) {
|
||||
const { previousPathname } = usePathnames();
|
||||
|
||||
// CORE
|
||||
const [hasLoaded, setHasLoaded] =
|
||||
useState(false);
|
||||
const [arePhotosMatted, setArePhotosMatted] =
|
||||
useState(MATTE_PHOTOS);
|
||||
const [swrTimestamp, setSwrTimestamp] =
|
||||
useState(Date.now());
|
||||
const [userEmail, setUserEmail] =
|
||||
useState<string>();
|
||||
const [nextPhotoAnimation, setNextPhotoAnimation] =
|
||||
useState<AnimationConfig>();
|
||||
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
|
||||
useState(true);
|
||||
const [isCommandKOpen, setIsCommandKOpen] =
|
||||
useState(false);
|
||||
const [adminUpdateTimes, setAdminUpdateTimes] = useState<Date[]>([]);
|
||||
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
|
||||
useState(false);
|
||||
// ADMIN
|
||||
const [userEmail, setUserEmail] =
|
||||
useState<string>();
|
||||
const [adminUpdateTimes, setAdminUpdateTimes] =
|
||||
useState<Date[]>([]);
|
||||
const [hiddenPhotosCount, setHiddenPhotosCount] =
|
||||
useState(0);
|
||||
// DEBUG
|
||||
const [arePhotosMatted, setArePhotosMatted] =
|
||||
useState(MATTE_PHOTOS);
|
||||
const [shouldDebugBlur, setShouldDebugBlur] =
|
||||
useState(false);
|
||||
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
|
||||
useState(false);
|
||||
|
||||
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
|
||||
|
||||
@ -42,6 +49,18 @@ export default function AppStateProvider({
|
||||
setUserEmail(data?.user?.email ?? undefined);
|
||||
logClientAuthUpdate(data);
|
||||
}, [data]);
|
||||
const isUserSignedIn = Boolean(userEmail);
|
||||
useEffect(() => {
|
||||
if (isUserSignedIn) {
|
||||
const timeout = setTimeout(() =>
|
||||
getPhotosTagHiddenMetaCachedAction().then(({ count }) =>
|
||||
setHiddenPhotosCount(count))
|
||||
, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
setHiddenPhotosCount(0);
|
||||
}
|
||||
}, [isUserSignedIn]);
|
||||
|
||||
const registerAdminUpdate = useCallback(() =>
|
||||
setAdminUpdateTimes(updates => [...updates, new Date()])
|
||||
@ -54,29 +73,33 @@ export default function AppStateProvider({
|
||||
return (
|
||||
<AppStateContext.Provider
|
||||
value={{
|
||||
// CORE
|
||||
previousPathname,
|
||||
hasLoaded,
|
||||
arePhotosMatted,
|
||||
setArePhotosMatted,
|
||||
setHasLoaded,
|
||||
swrTimestamp,
|
||||
invalidateSwr,
|
||||
setHasLoaded,
|
||||
isUserSignedIn: userEmail !== undefined,
|
||||
userEmail,
|
||||
setUserEmail,
|
||||
nextPhotoAnimation,
|
||||
setNextPhotoAnimation,
|
||||
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
|
||||
shouldRespondToKeyboardCommands,
|
||||
setShouldRespondToKeyboardCommands,
|
||||
isCommandKOpen,
|
||||
setIsCommandKOpen,
|
||||
// ADMIN
|
||||
userEmail,
|
||||
setUserEmail,
|
||||
isUserSignedIn,
|
||||
adminUpdateTimes,
|
||||
registerAdminUpdate,
|
||||
shouldShowBaselineGrid,
|
||||
shouldDebugBlur,
|
||||
hiddenPhotosCount,
|
||||
// DEBUG
|
||||
arePhotosMatted,
|
||||
setArePhotosMatted,
|
||||
setShouldDebugBlur,
|
||||
setShouldShowBaselineGrid,
|
||||
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
|
||||
shouldShowBaselineGrid,
|
||||
shouldDebugBlur,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -17,16 +17,15 @@ export default function FavsTag({
|
||||
} & EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
label={
|
||||
badged
|
||||
? <span className="inline-flex gap-1">
|
||||
{TAG_FAVS}
|
||||
<FaStar
|
||||
size={10}
|
||||
className="text-amber-500"
|
||||
/>
|
||||
</span>
|
||||
: TAG_FAVS}
|
||||
label={badged
|
||||
? <span className="inline-flex gap-1">
|
||||
{TAG_FAVS}
|
||||
<FaStar
|
||||
size={10}
|
||||
className="text-amber-500"
|
||||
/>
|
||||
</span>
|
||||
: TAG_FAVS}
|
||||
href={pathForTag(TAG_FAVS)}
|
||||
icon={!badged &&
|
||||
<FaStar
|
||||
|
||||
23
src/tag/HiddenHeader.tsx
Normal file
23
src/tag/HiddenHeader.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Photo, photoQuantityText } from '@/photo';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import HiddenTag from './HiddenTag';
|
||||
|
||||
export default function HiddenHeader({
|
||||
photos,
|
||||
selectedPhoto,
|
||||
count,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
key="HiddenHeader"
|
||||
entity={<HiddenTag contrast="high" />}
|
||||
entityDescription={photoQuantityText(count, false)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
src/tag/HiddenTag.tsx
Normal file
37
src/tag/HiddenTag.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { TAG_HIDDEN } from '.';
|
||||
import { pathForTag } from '@/site/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
import { AiOutlineEyeInvisible } from 'react-icons/ai';
|
||||
|
||||
export default function HiddenTag({
|
||||
type,
|
||||
badged,
|
||||
contrast,
|
||||
prefetch,
|
||||
countOnHover,
|
||||
}: {
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
label={badged
|
||||
? <span className="inline-flex gap-1">
|
||||
{TAG_HIDDEN}
|
||||
<AiOutlineEyeInvisible
|
||||
size={13}
|
||||
className="translate-y-[-1.5px]"
|
||||
/>
|
||||
</span>
|
||||
: TAG_HIDDEN}
|
||||
href={pathForTag(TAG_HIDDEN)}
|
||||
icon={!badged && <AiOutlineEyeInvisible size={16} />}
|
||||
type={type}
|
||||
hoverEntity={countOnHover}
|
||||
badged={badged}
|
||||
contrast={contrast}
|
||||
prefetch={prefetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -21,7 +21,7 @@ export default function TagHeader({
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
entity={isTagFavs(tag)
|
||||
? <FavsTag />
|
||||
? <FavsTag contrast="high" />
|
||||
: <PhotoTag tag={tag} contrast="high" />}
|
||||
entityVerb="Tagged"
|
||||
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
|
||||
|
||||
@ -11,7 +11,9 @@ import {
|
||||
} from '@/site/paths';
|
||||
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
||||
|
||||
export const TAG_FAVS = 'favs';
|
||||
// Reserved/virtual tags
|
||||
export const TAG_FAVS = 'favs'; // Reserved
|
||||
export const TAG_HIDDEN = 'hidden'; // Virtual
|
||||
|
||||
export type TagsWithMeta = {
|
||||
tag: string
|
||||
@ -21,12 +23,15 @@ export type TagsWithMeta = {
|
||||
export const formatTag = (tag?: string) =>
|
||||
capitalizeWords(tag?.replaceAll('-', ' '));
|
||||
|
||||
export const doesTagsStringIncludeFavs = (tags?: string) =>
|
||||
convertStringToArray(tags)?.some(tag => isTagFavs(tag));
|
||||
export const doesStringContainReservedTags = (tags?: string) =>
|
||||
convertStringToArray(tags)?.some(tag => (
|
||||
isTagFavs(tag) ||
|
||||
tag.toLowerCase() === TAG_HIDDEN
|
||||
));
|
||||
|
||||
export const titleForTag = (
|
||||
tag: string,
|
||||
photos:Photo[],
|
||||
photos:Photo[] = [],
|
||||
explicitCount?: number,
|
||||
) => [
|
||||
formatTag(tag),
|
||||
@ -54,7 +59,7 @@ export const sortTagsObjectWithoutFavs = (tags: TagsWithMeta) =>
|
||||
sortTagsObject(tags, TAG_FAVS);
|
||||
|
||||
export const descriptionForTaggedPhotos = (
|
||||
photos: Photo[],
|
||||
photos: Photo[] = [],
|
||||
dateBased?: boolean,
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
@ -86,3 +91,14 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||
|
||||
export const isPathFavs = (pathname?: string) =>
|
||||
getPathComponents(pathname).tag === TAG_FAVS;
|
||||
|
||||
export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => {
|
||||
if (hiddenPhotosCount > 0) {
|
||||
return tags
|
||||
.filter(({ tag }) => tag === TAG_FAVS)
|
||||
.concat({ tag: TAG_HIDDEN, count: hiddenPhotosCount })
|
||||
.concat(tags.filter(({ tag }) => tag !== TAG_FAVS));
|
||||
} else {
|
||||
return tags;
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user