* Make /db top-level module

* Create Album type

* Pin pnpm version

* Generalize query modules

* Finalize album postgres data type

* Remove temp albums prop

* Create basic album primitives

* Fix temporary album bugs

* Add albums to sidebar

* Disambiguate string date utilities

* Localize album language

* Add album join option to core photo queries

* Tweak album icon placement

* Add album photo detail page

* Refine Album data model

* Display album subhead when available

* Generate album og images

* Finalize album share modal

* Add albums to sitemap

* Statically pre-render albums

* Display tags on albums

* Add albums to cmd-k menu

* Handle album tag overflow

* Stop truncating album subheads

* Create core admin album views

* Make albums editable

* Create/edit albums on photo save, add delete album
This commit is contained in:
Sam Becker 2025-09-16 21:47:22 -05:00 committed by GitHub
parent 62e8392900
commit 1e66815a3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
166 changed files with 2667 additions and 1300 deletions

View File

@ -0,0 +1,53 @@
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache';
import { PATH_ADMIN, PATH_ADMIN_ALBUMS, pathForAlbum } from '@/app/path';
import PhotoLightbox from '@/photo/PhotoLightbox';
import { getAlbumFromSlug } from '@/album/query';
import AdminAlbumBadge from '@/admin/AdminAlbumBadge';
import AdminAlbumForm from '@/admin/AdminAlbumForm';
const MAX_PHOTO_TO_SHOW = 6;
interface Props {
params: Promise<{ album: string }>
}
export default async function AlbumPageEdit({
params,
}: Props) {
const { album: albumFromParams } = await params;
const albumSlug = decodeURIComponent(albumFromParams);
const album = await getAlbumFromSlug(albumSlug);
if (!album) { redirect(PATH_ADMIN); }
const [
{ count },
photos,
] = await Promise.all([
getPhotosMetaCached({ album }),
getPhotosCached({ album, limit: MAX_PHOTO_TO_SHOW }),
]);
if (count === 0) { redirect(PATH_ADMIN); }
return (
<AdminChildPage
backPath={PATH_ADMIN_ALBUMS}
backLabel="Albums"
breadcrumb={<AdminAlbumBadge {...{ album, count, hideBadge: true }} />}
>
<AdminAlbumForm {...{ album }}>
{photos.length > 0 &&
<PhotoLightbox
{...{ count, photos, album }}
maxPhotosToShow={MAX_PHOTO_TO_SHOW}
moreLink={pathForAlbum(album)}
/>}
</AdminAlbumForm>
</AdminChildPage>
);
};

18
app/admin/albums/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import AdminAlbumsTable from '@/admin/AdminAlbumsTable';
import { getAlbumsWithMeta } from '@/album/query';
import AppGrid from '@/components/AppGrid';
export default async function AdminTagsPage() {
const albums = await getAlbumsWithMeta();
return (
<AppGrid
contentMain={
<div className="space-y-6">
<div className="space-y-4">
<AdminAlbumsTable {...{ albums }} />
</div>
</div>}
/>
);
}

View File

@ -17,6 +17,7 @@ import {
getOptimizedPhotoUrlForManipulation, getOptimizedPhotoUrlForManipulation,
getStorageUrlsForPhoto, getStorageUrlsForPhoto,
} from '@/photo/storage'; } from '@/photo/storage';
import { getAlbumsWithMeta, getAlbumTitlesForPhoto } from '@/album/query';
export default async function PhotoEditPage({ export default async function PhotoEditPage({
params, params,
@ -27,11 +28,15 @@ export default async function PhotoEditPage({
const [ const [
photo, photo,
photoAlbumTitles,
albums,
uniqueTags, uniqueTags,
uniqueRecipes, uniqueRecipes,
uniqueFilms, uniqueFilms,
] = await Promise.all([ ] = await Promise.all([
getPhotoNoStore(photoId, true), getPhotoNoStore(photoId, true),
getAlbumTitlesForPhoto(photoId),
getAlbumsWithMeta(),
getUniqueTagsCached(), getUniqueTagsCached(),
getUniqueRecipesCached(), getUniqueRecipesCached(),
getUniqueFilmsCached(), getUniqueFilmsCached(),
@ -60,6 +65,8 @@ export default async function PhotoEditPage({
<PhotoEditPageClient {...{ <PhotoEditPageClient {...{
photo, photo,
photoStorageUrls, photoStorageUrls,
photoAlbumTitles,
albums,
uniqueTags, uniqueTags,
uniqueRecipes, uniqueRecipes,
uniqueFilms, uniqueFilms,

View File

@ -1,5 +1,5 @@
import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache'; import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache';
import { getPhotos, getPhotosInNeedOfUpdateCount } from '@/photo/db/query'; import { getPhotos, getPhotosInNeedOfUpdateCount } from '@/photo/query';
import { getPhotosMetaCached } from '@/photo/cache'; import { getPhotosMetaCached } from '@/photo/cache';
import AdminPhotosClient from '@/admin/AdminPhotosClient'; import AdminPhotosClient from '@/admin/AdminPhotosClient';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';

View File

@ -1,6 +1,6 @@
import AdminPhotosUpdateClient from '@/admin/AdminPhotosUpdateClient'; import AdminPhotosUpdateClient from '@/admin/AdminPhotosUpdateClient';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config'; import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getPhotosInNeedOfUpdate } from '@/photo/db/query'; import { getPhotosInNeedOfUpdate } from '@/photo/query';
export const maxDuration = 60; export const maxDuration = 60;

View File

@ -46,7 +46,7 @@ export default async function RecipePageEdit({
/> />
} }
> >
<AdminRecipeForm {...{ recipe, photos }}> <AdminRecipeForm {...{ recipe }}>
<PhotoLightbox <PhotoLightbox
{...{ count, photos, recipe }} {...{ count, photos, recipe }}
maxPhotosToShow={MAX_PHOTO_TO_SHOW} maxPhotosToShow={MAX_PHOTO_TO_SHOW}

View File

@ -1,6 +1,6 @@
import AdminRecipeTable from '@/admin/AdminRecipeTable'; import AdminRecipeTable from '@/admin/AdminRecipeTable';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import { getUniqueRecipes } from '@/photo/db/query'; import { getUniqueRecipes } from '@/photo/query';
export default async function AdminRecipesPage() { export default async function AdminRecipesPage() {
const recipes = await getUniqueRecipes().catch(() => []); const recipes = await getUniqueRecipes().catch(() => []);

View File

@ -12,7 +12,7 @@ interface Props {
params: Promise<{ tag: string }> params: Promise<{ tag: string }>
} }
export default async function PhotoPageEdit({ export default async function TagPageEdit({
params, params,
}: Props) { }: Props) {
const { tag: tagFromParams } = await params; const { tag: tagFromParams } = await params;
@ -35,7 +35,7 @@ export default async function PhotoPageEdit({
backLabel="Tags" backLabel="Tags"
breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />} breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
> >
<AdminTagForm {...{ tag, photos }}> <AdminTagForm {...{ tag }}>
<PhotoLightbox <PhotoLightbox
{...{ count, photos, tag }} {...{ count, photos, tag }}
maxPhotosToShow={MAX_PHOTO_TO_SHOW} maxPhotosToShow={MAX_PHOTO_TO_SHOW}

View File

@ -1,6 +1,6 @@
import AdminTagTable from '@/admin/AdminTagTable'; import AdminTagsTable from '@/admin/AdminTagsTable';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import { getUniqueTags } from '@/photo/db/query'; import { getUniqueTags } from '@/photo/query';
export default async function AdminTagsPage() { export default async function AdminTagsPage() {
const tags = await getUniqueTags().catch(() => []); const tags = await getUniqueTags().catch(() => []);
@ -10,7 +10,7 @@ export default async function AdminTagsPage() {
contentMain={ contentMain={
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<AdminTagTable {...{ tags }} /> <AdminTagsTable {...{ tags }} />
</div> </div>
</div>} </div>}
/> />

View File

@ -13,7 +13,8 @@ import {
BLUR_ENABLED, BLUR_ENABLED,
} from '@/app/config'; } from '@/app/config';
import ErrorNote from '@/components/ErrorNote'; import ErrorNote from '@/components/ErrorNote';
import { getRecipeTitleForData } from '@/photo/db/query'; import { getRecipeTitleForData } from '@/photo/query';
import { getAlbumsWithMeta } from '@/album/query';
export const maxDuration = 60; export const maxDuration = 60;
@ -48,11 +49,13 @@ export default async function UploadPage({ params, searchParams }: Params) {
} }
const [ const [
albums,
uniqueTags, uniqueTags,
uniqueRecipes, uniqueRecipes,
uniqueFilms, uniqueFilms,
recipeTitle, recipeTitle,
] = await Promise.all([ ] = await Promise.all([
getAlbumsWithMeta(),
getUniqueTagsCached(), getUniqueTagsCached(),
getUniqueRecipesCached(), getUniqueRecipesCached(),
getUniqueFilmsCached(), getUniqueFilmsCached(),
@ -83,6 +86,7 @@ export default async function UploadPage({ params, searchParams }: Params) {
? <UploadPageClient {...{ ? <UploadPageClient {...{
blobId, blobId,
formDataFromExif, formDataFromExif,
albums,
uniqueTags, uniqueTags,
uniqueRecipes, uniqueRecipes,
uniqueFilms, uniqueFilms,

View File

@ -0,0 +1,97 @@
import {
RELATED_GRID_PHOTOS_TO_SHOW,
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next/types';
import { redirect } from 'next/navigation';
import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/app/path';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosMetaCached, getPhotosNearIdCached } from '@/photo/cache';
import { cache } from 'react';
import { getAlbumFromSlug } from '@/album/query';
import { Album } from '@/album';
const getPhotosNearIdCachedCached = cache((photoId: string, album: Album) =>
getPhotosNearIdCached(
photoId,
{ album, limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 },
));
interface PhotoTagProps {
params: Promise<{ photoId: string, album: string }>
}
export async function generateMetadata({
params,
}: PhotoTagProps): Promise<Metadata> {
const { photoId, album: albumFromParams } = await params;
const albumSlug = decodeURIComponent(albumFromParams);
const album = await getAlbumFromSlug(albumSlug);
if (!album) { return {}; }
const { photo } = await getPhotosNearIdCachedCached(photoId, album);
if (!photo) { return {}; }
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const descriptionHtml = descriptionForPhoto(photo, true);
const images = absolutePathForPhotoImage(photo);
const url = absolutePathForPhoto({ photo, album });
return {
title,
description: descriptionHtml,
openGraph: {
title,
images,
description,
url,
},
twitter: {
title,
description,
images,
card: 'summary_large_image',
},
};
}
export default async function PhotoAlbumPage({
params,
}: PhotoTagProps) {
const { photoId, album: albumFromParams } = await params;
const albumSlug = decodeURIComponent(albumFromParams);
const album = await getAlbumFromSlug(albumSlug);
if (!album) { redirect(PATH_ROOT); }
const { photo, photos, photosGrid, indexNumber } =
await getPhotosNearIdCachedCached(photoId, album);
if (!photo) { redirect(PATH_ROOT); }
const { count, dateRange } = await getPhotosMetaCached({ album });
return (
<PhotoDetailPage {...{
photo,
photos,
photosGrid,
album,
indexNumber,
count,
dateRange,
}} />
);
}

View File

@ -0,0 +1,52 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import AlbumImageResponse from '@/album/AlbumImageResponse';
import { getAlbumFromSlug, getAlbumsWithMeta } from '@/album/query';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
'albums',
'image',
getAlbumsWithMeta,
albums => albums.map(({ album }) => ({ album: album.slug })),
);
export async function GET(
_: Request,
context: { params: Promise<{ album: string }> },
) {
const { album: albumParam } = await context.params;
const album = await getAlbumFromSlug(decodeURIComponent(albumParam));
if (!album) { return new Response('Album not found', { status: 404 }); }
const [
photos,
{ fontFamily, fonts },
headers,
] = await Promise.all([
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, album }),
getIBMPlexMono(),
getImageResponseCacheControlHeaders(),
]);
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
return new ImageResponse(
<AlbumImageResponse {...{
album,
photos,
width,
height,
fontFamily,
}}/>,
{ width, height, fonts, headers },
);
}

View File

@ -0,0 +1,99 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getPhotos } from '@/photo/query';
import { PATH_ROOT } from '@/app/path';
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { cache } from 'react';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
import AlbumOverview from '@/album/AlbumOverview';
import {
getAlbumFromSlug,
getAlbumsWithMeta,
getTagsForAlbum,
} from '@/album/query';
import { Album, generateMetaForAlbum } from '@/album';
import { getPhotosAlbumDataCached } from '@/album/data';
const getPhotosAlbumDataCachedCached = cache((album: Album) =>
getPhotosAlbumDataCached({ album, limit: INFINITE_SCROLL_GRID_INITIAL}));
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
'albums',
'page',
getAlbumsWithMeta,
albums => albums.map(({ album }) => ({ album: album.slug })),
);
interface AlbumProps {
params: Promise<{ album: string }>
}
export async function generateMetadata({
params,
}: AlbumProps): Promise<Metadata> {
const { album: albumFromParams } = await params;
const albumSlug = decodeURIComponent(albumFromParams);
const album = await getAlbumFromSlug(albumSlug);
if (!album) { return {}; }
const [
photos,
{ count, dateRange },
] = await getPhotosAlbumDataCachedCached(album);
if (photos.length === 0) { return {}; }
const appText = await getAppText();
const {
url,
title,
description,
images,
} = generateMetaForAlbum(album, photos, appText, count, dateRange);
return {
title,
openGraph: {
title,
description,
images,
url,
},
twitter: {
images,
description,
card: 'summary_large_image',
},
description,
};
}
export default async function AlbumPage({
params,
}:AlbumProps) {
const { album: albumFromParams } = await params;
const albumSlug = decodeURIComponent(albumFromParams);
const album = await getAlbumFromSlug(albumSlug);
if (!album) { redirect(PATH_ROOT); }
const photos = await getPhotos({ album });
const tags = await getTagsForAlbum(album.id);
return (
<AlbumOverview {...{
album,
photos,
tags,
count: photos.length,
}} />
);
}

View File

@ -3,12 +3,11 @@ import {
IMAGE_OG_DIMENSION_SMALL, IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY, MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response'; } from '@/image-response';
import FilmImageResponse from import FilmImageResponse from '@/film/FilmImageResponse';
'@/image-response/FilmImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getUniqueFilms } from '@/photo/db/query'; import { getUniqueFilms } from '@/photo/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -1,5 +1,5 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueFilms } from '@/photo/db/query'; import { getUniqueFilms } from '@/photo/query';
import { generateMetaForFilm } from '@/film'; import { generateMetaForFilm } from '@/film';
import FilmOverview from '@/film/FilmOverview'; import FilmOverview from '@/film/FilmOverview';
import { getPhotosFilmDataCached } from '@/film/data'; import { getPhotosFilmDataCached } from '@/film/data';

View File

@ -6,10 +6,9 @@ import {
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import FocalLengthImageResponse from import FocalLengthImageResponse from '@/focal/FocalLengthImageResponse';
'@/image-response/FocalLengthImageResponse';
import { formatFocalLength, getFocalLengthFromString } from '@/focal'; import { formatFocalLength, getFocalLengthFromString } from '@/focal';
import { getUniqueFocalLengths } from '@/photo/db/query'; import { getUniqueFocalLengths } from '@/photo/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -2,7 +2,7 @@ import { generateMetaForFocalLength, getFocalLengthFromString } from '@/focal';
import FocalLengthOverview from '@/focal/FocalLengthOverview'; import FocalLengthOverview from '@/focal/FocalLengthOverview';
import { getPhotosFocalLengthDataCached } from '@/focal/data'; import { getPhotosFocalLengthDataCached } from '@/focal/data';
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueFocalLengths } from '@/photo/db/query'; import { getUniqueFocalLengths } from '@/photo/query';
import { PATH_ROOT } from '@/app/path'; import { PATH_ROOT } from '@/app/path';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@ -2,12 +2,12 @@ import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { cache } from 'react'; import { cache } from 'react';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/query';
import PhotoFullPage from '@/photo/PhotoFullPage'; import PhotoFullPage from '@/photo/PhotoFullPage';
import { getPhotosMetaCached } from '@/photo/cache'; import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/sort'; import { SortProps } from '@/photo/sort';
import { getSortOptionsFromParams } from '@/photo/sort/path'; import { getSortOptionsFromParams } from '@/photo/sort/path';
import { PhotoQueryOptions } from '@/photo/db'; import { PhotoQueryOptions } from '@/db';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed'; import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
export const maxDuration = 60; export const maxDuration = 60;

View File

@ -2,7 +2,7 @@ import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { cache } from 'react'; import { cache } from 'react';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/query';
import PhotoFullPage from '@/photo/PhotoFullPage'; import PhotoFullPage from '@/photo/PhotoFullPage';
import { getPhotosMetaCached } from '@/photo/cache'; import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config'; import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';

View File

@ -1,7 +1,7 @@
import { generateOgImageMetaForPhotos } from '@/photo'; import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/query';
import { cache } from 'react'; import { cache } from 'react';
import PhotoGridPage from '@/photo/PhotoGridPage'; import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache'; import { getDataForCategoriesCached } from '@/category/cache';
@ -9,7 +9,7 @@ import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/sort'; import { SortProps } from '@/photo/sort';
import { getSortOptionsFromParams } from '@/photo/sort/path'; import { getSortOptionsFromParams } from '@/photo/sort/path';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed'; import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { PhotoQueryOptions } from '@/photo/db'; import { PhotoQueryOptions } from '@/db';
export const maxDuration = 60; export const maxDuration = 60;

View File

@ -1,7 +1,7 @@
import { generateOgImageMetaForPhotos } from '@/photo'; import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/query';
import { cache } from 'react'; import { cache } from 'react';
import PhotoGridPage from '@/photo/PhotoGridPage'; import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache'; import { getDataForCategoriesCached } from '@/category/cache';

View File

@ -3,7 +3,7 @@ import {
IMAGE_OG_DIMENSION_SMALL, IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_OG, MAX_PHOTOS_TO_SHOW_OG,
} from '@/image-response'; } from '@/image-response';
import HomeImageResponse from '@/image-response/HomeImageResponse'; import HomeImageResponse from '@/app/HomeImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { APP_OG_IMAGE_QUERY_OPTIONS } from '@/feed'; import { APP_OG_IMAGE_QUERY_OPTIONS } from '@/feed';

View File

@ -6,13 +6,13 @@ import {
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getUniqueLenses } from '@/photo/db/query'; import { getUniqueLenses } from '@/photo/query';
import { import {
getLensFromParams, getLensFromParams,
LensProps, LensProps,
safelyGenerateLensStaticParams, safelyGenerateLensStaticParams,
} from '@/lens'; } from '@/lens';
import LensImageResponse from '@/image-response/LensImageResponse'; import LensImageResponse from '@/lens/LensImageResponse';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -1,7 +1,7 @@
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { cache } from 'react'; import { cache } from 'react';
import { getUniqueLenses } from '@/photo/db/query'; import { getUniqueLenses } from '@/photo/query';
import { generateMetaForLens } from '@/lens/meta'; import { generateMetaForLens } from '@/lens/meta';
import { getPhotosLensDataCached } from '@/lens/data'; import { getPhotosLensDataCached } from '@/lens/data';
import LensOverview from '@/lens/LensOverview'; import LensOverview from '@/lens/LensOverview';

View File

@ -1,6 +1,6 @@
import { getPhotoCached } from '@/photo/cache'; import { getPhotoCached } from '@/photo/cache';
import { IMAGE_OG_DIMENSION } from '@/image-response'; import { IMAGE_OG_DIMENSION } from '@/image-response';
import PhotoImageResponse from '@/image-response/PhotoImageResponse'; import PhotoImageResponse from '@/photo/PhotoImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { staticallyGeneratePhotosIfConfigured } from '@/app/static'; import { staticallyGeneratePhotosIfConfigured } from '@/app/static';

View File

@ -2,7 +2,7 @@ import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { cache } from 'react'; import { cache } from 'react';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/query';
import { GRID_HOMEPAGE_ENABLED, USER_DEFAULT_SORT_OPTIONS } from '@/app/config'; import { GRID_HOMEPAGE_ENABLED, USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { NULL_CATEGORY_DATA } from '@/category/data'; import { NULL_CATEGORY_DATA } from '@/category/data';
import PhotoFullPage from '@/photo/PhotoFullPage'; import PhotoFullPage from '@/photo/PhotoFullPage';

View File

@ -4,7 +4,7 @@ import {
MAX_PHOTOS_TO_SHOW_PER_CATEGORY, MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response'; } from '@/image-response';
import RecentsImageResponse from import RecentsImageResponse from
'@/image-response/RecentsImageResponse'; '@/recents/RecentsImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getAppText } from '@/i18n/state/server'; import { getAppText } from '@/i18n/state/server';

View File

@ -6,8 +6,8 @@ import {
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getUniqueRecipes } from '@/photo/db/query'; import { getUniqueRecipes } from '@/photo/query';
import RecipeImageResponse from '@/image-response/RecipeImageResponse'; import RecipeImageResponse from '@/recipe/RecipeImageResponse';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -1,5 +1,5 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueRecipes } from '@/photo/db/query'; import { getUniqueRecipes } from '@/photo/query';
import { PATH_ROOT } from '@/app/path'; import { PATH_ROOT } from '@/app/path';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@ -4,11 +4,11 @@ import {
IMAGE_OG_DIMENSION_SMALL, IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY, MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response'; } from '@/image-response';
import CameraImageResponse from '@/image-response/CameraImageResponse'; import CameraImageResponse from '@/camera/CameraImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getUniqueCameras } from '@/photo/db/query'; import { getUniqueCameras } from '@/photo/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -5,7 +5,7 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getPhotosCameraDataCached } from '@/camera/data'; import { getPhotosCameraDataCached } from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview'; import CameraOverview from '@/camera/CameraOverview';
import { cache } from 'react'; import { cache } from 'react';
import { getUniqueCameras } from '@/photo/db/query'; import { getUniqueCameras } from '@/photo/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server'; import { getAppText } from '@/i18n/state/server';

View File

@ -3,6 +3,7 @@ import { getDataForCategoriesCached } from '@/category/cache';
import { import {
ABSOLUTE_PATH_FULL, ABSOLUTE_PATH_FULL,
ABSOLUTE_PATH_GRID, ABSOLUTE_PATH_GRID,
absolutePathForAlbum,
absolutePathForCamera, absolutePathForCamera,
absolutePathForFilm, absolutePathForFilm,
absolutePathForFocalLength, absolutePathForFocalLength,
@ -15,7 +16,7 @@ import {
} from '@/app/path'; } from '@/app/path';
import { isTagFavs } from '@/tag'; import { isTagFavs } from '@/tag';
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config'; import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
import { getPhotoIdsAndUpdatedAt } from '@/photo/db/query'; import { getPhotoIdsAndUpdatedAt } from '@/photo/query';
// Cache for 24 hours // Cache for 24 hours
export const revalidate = 86_400; export const revalidate = 86_400;
@ -33,6 +34,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
years, years,
cameras, cameras,
lenses, lenses,
albums,
tags, tags,
recipes, recipes,
films, films,
@ -45,6 +47,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
years: [], years: [],
cameras: [], cameras: [],
lenses: [], lenses: [],
albums: [],
tags: [], tags: [],
recipes: [], recipes: [],
films: [], films: [],
@ -58,6 +61,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
...years.map(({ lastModified }) => lastModified), ...years.map(({ lastModified }) => lastModified),
...cameras.map(({ lastModified }) => lastModified), ...cameras.map(({ lastModified }) => lastModified),
...lenses.map(({ lastModified }) => lastModified), ...lenses.map(({ lastModified }) => lastModified),
...albums.map(({ lastModified }) => lastModified),
...tags.map(({ lastModified }) => lastModified), ...tags.map(({ lastModified }) => lastModified),
...recipes.map(({ lastModified }) => lastModified), ...recipes.map(({ lastModified }) => lastModified),
...films.map(({ lastModified }) => lastModified), ...films.map(({ lastModified }) => lastModified),
@ -104,6 +108,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: PRIORITY_CATEGORY, priority: PRIORITY_CATEGORY,
lastModified, lastModified,
})), })),
// Albums
...albums.map(({ album, lastModified }) => ({
url: absolutePathForAlbum(album),
priority: PRIORITY_CATEGORY,
lastModified,
})),
// Tags // Tags
...tags.map(({ tag, lastModified }) => ({ ...tags.map(({ tag, lastModified }) => ({
url: absolutePathForTag(tag), url: absolutePathForTag(tag),

View File

@ -3,11 +3,11 @@ import {
IMAGE_OG_DIMENSION_SMALL, IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY, MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response'; } from '@/image-response';
import TagImageResponse from '@/image-response/TagImageResponse'; import TagImageResponse from '@/tag/TagImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getUniqueTags } from '@/photo/db/query'; import { getUniqueTags } from '@/photo/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -1,5 +1,5 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueTags } from '@/photo/db/query'; import { getUniqueTags } from '@/photo/query';
import { PATH_ROOT } from '@/app/path'; import { PATH_ROOT } from '@/app/path';
import { generateMetaForTag } from '@/tag'; import { generateMetaForTag } from '@/tag';
import TagOverview from '@/tag/TagOverview'; import TagOverview from '@/tag/TagOverview';

View File

@ -4,7 +4,7 @@ import {
MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT, MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
} from '@/image-response'; } from '@/image-response';
import TemplateImageResponse from import TemplateImageResponse from
'@/image-response/TemplateImageResponse'; '@/app/TemplateImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response'; import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response';

View File

@ -4,7 +4,7 @@ import {
MAX_PHOTOS_TO_SHOW_TEMPLATE, MAX_PHOTOS_TO_SHOW_TEMPLATE,
} from '@/image-response'; } from '@/image-response';
import TemplateImageResponse from import TemplateImageResponse from
'@/image-response/TemplateImageResponse'; '@/app/TemplateImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response'; import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response';

View File

@ -3,12 +3,11 @@ import {
IMAGE_OG_DIMENSION_SMALL, IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY, MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response'; } from '@/image-response';
import YearImageResponse from import YearImageResponse from '@/year/YearImageResponse';
'@/image-response/YearImageResponse';
import { getIBMPlexMono } from '@/app/font'; import { getIBMPlexMono } from '@/app/font';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { getUniqueYears } from '@/photo/db/query'; import { getUniqueYears } from '@/photo/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(

View File

@ -1,8 +1,8 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueYears } from '@/photo/db/query'; import { getUniqueYears } from '@/photo/query';
import { generateMetaForYear } from '@/years/meta'; import { generateMetaForYear } from '@/year/meta';
import YearOverview from '@/years/YearOverview'; import YearOverview from '@/year/YearOverview';
import { getPhotosYearDataCached } from '@/years/data'; import { getPhotosYearDataCached } from '@/year/data';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { cache } from 'react'; import { cache } from 'react';
import { PATH_ROOT } from '@/app/path'; import { PATH_ROOT } from '@/app/path';

View File

@ -8,6 +8,7 @@
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'", "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"packageManager": "pnpm@10.16.1",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.28", "@ai-sdk/openai": "^2.0.28",
"@ai-sdk/rsc": "^1.0.41", "@ai-sdk/rsc": "^1.0.41",

1122
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
import AdminBadge from './AdminBadge';
import { Album } from '@/album';
import PhotoAlbum from '@/album/PhotoAlbum';
export default async function AdminAlbumBadge({
album,
count,
hideBadge,
}: {
album: Album,
count: number,
hideBadge?: boolean,
}) {
return (
<AdminBadge
entity={<PhotoAlbum {...{ album }} hoverType="image" />}
count={count}
hideBadge={hideBadge}
/>
);
}

View File

@ -0,0 +1,76 @@
'use client';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link';
import { PATH_ADMIN_ALBUMS } from '@/app/path';
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { ReactNode, useCallback, useMemo, useState} from 'react';
import { useAppState } from '@/app/AppState';
import { Album } from '@/album';
import { ALBUM_FORM_META } from '@/album/form';
import { parameterize } from '@/utility/string';
import { updateAlbumAction } from '@/album/actions';
import clsx from 'clsx/lite';
export default function AdminAlbumForm({
album,
children,
}: {
album: Album
children?: ReactNode
}) {
const { invalidateSwr } = useAppState();
const [albumForm, setAlbumForm] = useState<Album>(album);
const isFormValid = useMemo(() => {
return ALBUM_FORM_META.every(({ key, required }) => {
return !required || Boolean(albumForm[key]);
});
}, [albumForm]);
const updateAlbum = useCallback((key: keyof Album, value: string) => {
setAlbumForm(form => ({
...form,
[key]: value,
...key === 'title' && { slug: parameterize(value) },
}));
}, []);
return (
<form
action={updateAlbumAction}
className="max-w-[38rem] space-y-4"
>
{ALBUM_FORM_META
.filter(({ hidden }) => !hidden)
.map(({ key, label, type, readOnly }) => (
<FieldsetWithStatus
key={key}
id={key}
type={type}
label={label ?? key}
value={albumForm[key] ? `${albumForm[key]}` : ''}
onChange={value => updateAlbum(key, value)}
isModified={albumForm[key] !== album[key]}
readOnly={readOnly}
className={clsx(key === 'description' && '[&_textarea]:h-36')}
/>))}
{children}
<div className="flex gap-3">
<Link
className="button"
href={PATH_ADMIN_ALBUMS}
>
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid}
onFormSubmit={invalidateSwr}
>
Update
</SubmitButtonWithStatus>
</div>
</form>
);
}

View File

@ -0,0 +1,46 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react';
import DeleteFormButton from '@/admin/DeleteFormButton';
import { photoQuantityText } from '@/photo';
import EditButton from '@/admin/EditButton';
import { pathForAdminAlbumEdit } from '@/app/path';
import { clsx } from 'clsx/lite';
import { getAppText } from '@/i18n/state/server';
import { Albums } from '@/album';
import AdminAlbumBadge from './AdminAlbumBadge';
import { deleteAlbumAction } from '@/album/actions';
export default async function AdminAlbumsTable({
albums,
}: {
albums: Albums
}) {
const appText = await getAppText();
return (
<AdminTable>
{albums.map(({ album, count }) =>
<Fragment key={album.slug}>
<div className="pr-2 col-span-2">
<AdminAlbumBadge {...{ album, count }} />
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton path={pathForAdminAlbumEdit(album)} />
<FormWithConfirm
action={deleteAlbumAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${album.title}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`}
>
<input type="hidden" name="album" value={album.id} />
<DeleteFormButton clearLocalState />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminTable>
);
}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { import {
PATH_ADMIN_ALBUMS,
PATH_ADMIN_CONFIGURATION, PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS, PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
@ -32,6 +33,7 @@ import SwitcherItemMenu from '@/components/switcher/SwitcherItemMenu';
import { MoreMenuSection } from '@/components/more/MoreMenu'; import { MoreMenuSection } from '@/components/more/MoreMenu';
import { FiXSquare } from 'react-icons/fi'; import { FiXSquare } from 'react-icons/fi';
import { useSelectPhotosState } from './select/SelectPhotosState'; import { useSelectPhotosState } from './select/SelectPhotosState';
import IconAlbum from '@/components/icons/IconAlbum';
export default function AdminAppMenu({ export default function AdminAppMenu({
isOpen, isOpen,
@ -44,6 +46,7 @@ export default function AdminAppMenu({
photosCountTotal = 0, photosCountTotal = 0,
photosCountNeedSync = 0, photosCountNeedSync = 0,
uploadsCount = 0, uploadsCount = 0,
albumsCount = 0,
tagsCount = 0, tagsCount = 0,
recipesCount = 0, recipesCount = 0,
isLoadingAdminData, isLoadingAdminData,
@ -83,8 +86,8 @@ export default function AdminAppMenu({
label: appText.admin.uploadPlural, label: appText.admin.uploadPlural,
annotation: `${uploadsCount}`, annotation: `${uploadsCount}`,
icon: <IconFolder icon: <IconFolder
size={16} size={15}
className="translate-x-[1px] translate-y-[0.5px]" className="translate-x-[0.5px] translate-y-[0.5px]"
/>, />,
href: PATH_ADMIN_UPLOADS, href: PATH_ADMIN_UPLOADS,
}); });
@ -122,6 +125,17 @@ export default function AdminAppMenu({
href: PATH_ADMIN_PHOTOS, href: PATH_ADMIN_PHOTOS,
}); });
} }
if (albumsCount) {
items.push({
label: appText.admin.manageAlbums,
annotation: `${albumsCount}`,
icon: <IconAlbum
size={15}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>,
href: PATH_ADMIN_ALBUMS,
});
}
if (tagsCount) { if (tagsCount) {
items.push({ items.push({
label: appText.admin.manageTags, label: appText.admin.manageTags,
@ -186,6 +200,7 @@ export default function AdminAppMenu({
photosCountTotal, photosCountTotal,
recipesCount, recipesCount,
showAppInsightsLink, showAppInsightsLink,
albumsCount,
tagsCount, tagsCount,
uploadsCount, uploadsCount,
]); ]);

42
src/admin/AdminBadge.tsx Normal file
View File

@ -0,0 +1,42 @@
import { photoLabelForCount } from '@/photo';
import { clsx } from 'clsx/lite';
import Badge from '@/components/Badge';
import { getAppText } from '@/i18n/state/server';
import { ReactNode } from 'react';
export default async function AdminBadge({
entity,
count,
hideBadge,
className,
}: {
entity: ReactNode,
count: number,
hideBadge?: boolean,
className?: string,
}) {
const appText = await getAppText();
const renderBadgeContent = () =>
<div className={clsx(
'inline-flex items-center gap-2',
// Fix nested EntityLink-in-Badge quirk
'[&>*>*:first-child]:items-center',
className,
)}>
{entity}
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count, appText)}
</span>
</div>
</div>;
return (
hideBadge
? renderBadgeContent()
: <Badge className="py-[3px]!">{renderBadgeContent()}</Badge>
);
}

View File

@ -6,6 +6,7 @@ import {
getUniqueTagsCached, getUniqueTagsCached,
} from '@/photo/cache'; } from '@/photo/cache';
import { import {
PATH_ADMIN_ALBUMS,
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_RECIPES, PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
@ -13,11 +14,13 @@ import {
} from '@/app/path'; } from '@/app/path';
import AdminNavClient from './AdminNavClient'; import AdminNavClient from './AdminNavClient';
import { getAppText } from '@/i18n/state/server'; import { getAppText } from '@/i18n/state/server';
import { getAlbumsWithMeta } from '@/album/query';
export default async function AdminNav() { export default async function AdminNav() {
const [ const [
countPhotos, countPhotos,
countUploads, countUploads,
countAlbums,
countTags, countTags,
countRecipes, countRecipes,
mostRecentPhotoUpdateTime, mostRecentPhotoUpdateTime,
@ -31,6 +34,8 @@ export default async function AdminNav() {
console.error(`Error getting blob upload urls: ${e}`); console.error(`Error getting blob upload urls: ${e}`);
return 0; return 0;
}), }),
getAlbumsWithMeta().then(albums => albums.length)
.catch(() => 0),
getUniqueTagsCached().then(tags => tags.length) getUniqueTagsCached().then(tags => tags.length)
.catch(() => 0), .catch(() => 0),
getUniqueRecipesCached().then(recipes => recipes.length) getUniqueRecipesCached().then(recipes => recipes.length)
@ -56,6 +61,13 @@ export default async function AdminNav() {
count: countUploads, count: countUploads,
}); } }); }
// Albums
if (countAlbums > 0) { items.push({
label: appText.category.albumPlural,
href: PATH_ADMIN_ALBUMS,
count: countAlbums,
}); }
// Tags // Tags
if (countTags > 0) { items.push({ if (countTags > 0) { items.push({
label: appText.category.tagPlural, label: appText.category.tagPlural,

View File

@ -19,7 +19,7 @@ export default async function AdminRecipeBadge({
<div className={clsx( <div className={clsx(
'inline-flex items-center gap-2', 'inline-flex items-center gap-2',
)}> )}>
<PhotoRecipe {...{ recipe }} /> <PhotoRecipe {...{ recipe }} hoverType="image" />
<div className="text-dim uppercase"> <div className="text-dim uppercase">
<span>{count}</span> <span>{count}</span>
<span className="hidden xs:inline-block"> <span className="hidden xs:inline-block">

View File

@ -1,10 +1,7 @@
import PhotoTag from '@/tag/PhotoTag'; import PhotoTag from '@/tag/PhotoTag';
import { photoLabelForCount } from '@/photo';
import { clsx } from 'clsx/lite';
import PhotoFavs from '@/tag/PhotoFavs'; import PhotoFavs from '@/tag/PhotoFavs';
import { isTagFavs } from '@/tag'; import { isTagFavs } from '@/tag';
import Badge from '@/components/Badge'; import AdminBadge from './AdminBadge';
import { getAppText } from '@/i18n/state/server';
export default async function AdminTagBadge({ export default async function AdminTagBadge({
tag, tag,
@ -15,30 +12,14 @@ export default async function AdminTagBadge({
count: number, count: number,
hideBadge?: boolean, hideBadge?: boolean,
}) { }) {
const appText = await getAppText();
const renderBadgeContent = () =>
<div className={clsx(
'inline-flex items-center gap-2',
// Fix nested EntityLink-in-Badge quirk for tags
'[&>*>*:first-child]:items-center',
isTagFavs(tag) && 'translate-y-[0.5px]',
)}>
{isTagFavs(tag)
? <PhotoFavs />
: <PhotoTag {...{ tag }} />}
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count, appText)}
</span>
</div>
</div>;
return ( return (
hideBadge <AdminBadge
? renderBadgeContent() className={isTagFavs(tag) ? 'translate-y-[-0.5px]' : undefined}
: <Badge className="py-[3px]!">{renderBadgeContent()}</Badge> entity={isTagFavs(tag)
? <PhotoFavs hoverType="image" />
: <PhotoTag {...{ tag }} hoverType="image" />}
count={count}
hideBadge={hideBadge}
/>
); );
} }

View File

@ -11,7 +11,7 @@ import { clsx } from 'clsx/lite';
import AdminTagBadge from './AdminTagBadge'; import AdminTagBadge from './AdminTagBadge';
import { getAppText } from '@/i18n/state/server'; import { getAppText } from '@/i18n/state/server';
export default async function AdminTagTable({ export default async function AdminTagsTable({
tags, tags,
}: { }: {
tags: Tags tags: Tags

View File

@ -12,11 +12,12 @@ import {
getUniqueTags, getUniqueTags,
getUniqueRecipes, getUniqueRecipes,
getPhotosInNeedOfUpdateCount, getPhotosInNeedOfUpdateCount,
} from '@/photo/db/query'; } from '@/photo/query';
import { import {
getGitHubMetaForCurrentApp, getGitHubMetaForCurrentApp,
indicatorStatusForSignificantInsights, indicatorStatusForSignificantInsights,
} from './insights'; } from './insights';
import { getAlbumsWithMeta } from '@/album/query';
export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>; export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>;
@ -28,6 +29,7 @@ export const getAdminDataAction = async () =>
photosCountNeedSync, photosCountNeedSync,
codeMeta, codeMeta,
uploadsCount, uploadsCount,
albumsCount,
tagsCount, tagsCount,
recipesCount, recipesCount,
] = await Promise.all([ ] = await Promise.all([
@ -45,6 +47,9 @@ export const getAdminDataAction = async () =>
console.error(`Error getting blob upload urls: ${e}`); console.error(`Error getting blob upload urls: ${e}`);
return 0; return 0;
}), }),
getAlbumsWithMeta()
.then(albums => albums.length)
.catch(() => 0),
getUniqueTags() getUniqueTags()
.then(tags => tags.length) .then(tags => tags.length)
.catch(() => 0), .catch(() => 0),
@ -71,6 +76,7 @@ export const getAdminDataAction = async () =>
photosCountNeedSync, photosCountNeedSync,
photosCountTotal, photosCountTotal,
uploadsCount, uploadsCount,
albumsCount,
tagsCount, tagsCount,
recipesCount, recipesCount,
insightsIndicatorStatus, insightsIndicatorStatus,

View File

@ -7,7 +7,7 @@ import {
getUniqueRecipes, getUniqueRecipes,
getUniqueTags, getUniqueTags,
getPhotosInNeedOfUpdateCount, getPhotosInNeedOfUpdateCount,
} from '@/photo/db/query'; } from '@/photo/query';
import AdminAppInsightsClient from './AdminAppInsightsClient'; import AdminAppInsightsClient from './AdminAppInsightsClient';
import { getAllInsights, getGitHubMetaForCurrentApp } from '.'; import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
import { USED_DEPRECATED_ENV_VARS } from '@/app/config'; import { USED_DEPRECATED_ENV_VARS } from '@/app/config';

View File

@ -2,7 +2,7 @@
import ScoreCard from '@/components/ScoreCard'; import ScoreCard from '@/components/ScoreCard';
import ScoreCardRow from '@/components/ScoreCardRow'; import ScoreCardRow from '@/components/ScoreCardRow';
import { dateRangeForPhotos } from '@/photo'; import { formattedDateRangeForPhotos } from '@/photo';
import { FaArrowRight, FaCircleInfo, FaRegCalendar } from 'react-icons/fa6'; import { FaArrowRight, FaCircleInfo, FaRegCalendar } from 'react-icons/fa6';
import { MdAspectRatio } from 'react-icons/md'; import { MdAspectRatio } from 'react-icons/md';
import { PiWarningBold } from 'react-icons/pi'; import { PiWarningBold } from 'react-icons/pi';
@ -125,7 +125,8 @@ export default function AdminAppInsightsClient({
noStaticOptimization, noStaticOptimization,
} = insights; } = insights;
const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange); const { descriptionWithSpaces } =
formattedDateRangeForPhotos(undefined, dateRange);
const branchLink = <a const branchLink = <a
className="truncate" className="truncate"

View File

@ -14,7 +14,7 @@ import {
AI_CONTENT_GENERATION_ENABLED, AI_CONTENT_GENERATION_ENABLED,
HAS_DEPRECATED_ENV_VARS, HAS_DEPRECATED_ENV_VARS,
} from '@/app/config'; } from '@/app/config';
import { PhotoDateRange } from '@/photo'; import { PhotoDateRangePostgres } from '@/photo';
import { getGitHubMeta } from '@/platforms/github'; import { getGitHubMeta } from '@/platforms/github';
const BASIC_PHOTO_INSTALLATION_COUNT = 32; const BASIC_PHOTO_INSTALLATION_COUNT = 32;
@ -64,7 +64,7 @@ export interface PhotoStats {
recipesCount: number recipesCount: number
filmsCount: number filmsCount: number
focalLengthsCount: number focalLengthsCount: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
} }
export const getGitHubMetaForCurrentApp = () => export const getGitHubMetaForCurrentApp = () =>

91
src/album/AlbumHeader.tsx Normal file
View File

@ -0,0 +1,91 @@
import { Photo, PhotoDateRangePostgres } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import {
AI_CONTENT_GENERATION_ENABLED,
SHOW_CATEGORY_IMAGE_HOVERS,
} from '@/app/config';
import { getAppText } from '@/i18n/state/server';
import { Album, descriptionForAlbumPhotos } from '.';
import { safelyParseFormattedHtml } from '@/utility/html';
import PhotoAlbum from './PhotoAlbum';
import PhotoTag from '@/tag/PhotoTag';
import IconTag from '@/components/icons/IconTag';
import MaskedScroll from '@/components/MaskedScroll';
export default async function AlbumHeader({
album,
photos,
tags = [],
selectedPhoto,
indexNumber,
count,
dateRange,
showAlbumMeta,
}: {
album: Album
photos: Photo[]
tags?: string[]
selectedPhoto?: Photo
indexNumber?: number
count?: number
dateRange?: PhotoDateRangePostgres
showAlbumMeta?: boolean
}) {
const appText = await getAppText();
return (
<PhotoHeader
album={album}
entity={<PhotoAlbum
album={album}
contrast="high"
hoverType="none"
/>}
entityDescription={descriptionForAlbumPhotos(
photos,
appText,
undefined,
count,
)}
photos={photos}
selectedPhoto={selectedPhoto}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
richContent={showAlbumMeta
? <div className="space-y-2">
{album.subhead &&
<div className="text-medium mb-6 uppercase font-medium">
{album.subhead}
</div>}
{tags.length > 0 &&
<MaskedScroll
className="whitespace-nowrap space-x-1.5"
direction="horizontal"
>
<IconTag className="inline-block text-dim translate-y-[-0.5px]" />
{tags.map(tag => (
<PhotoTag
key={tag}
tag={tag}
badged
type="text-only"
contrast="low"
hoverType={SHOW_CATEGORY_IMAGE_HOVERS ? 'image' : 'none'}
prefetch={false}
/>
))}
</MaskedScroll>}
{album.description &&
<div
className="text-medium [&>a]:underline"
dangerouslySetInnerHTML={{
__html: safelyParseFormattedHtml(album.description),
}}
/>}
</div>
: undefined}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);
}

View File

@ -0,0 +1,46 @@
import type { Photo } from '../photo';
import ImageCaption from '@/image-response/components/ImageCaption';
import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
import ImageContainer from '@/image-response/components/ImageContainer';
import type { NextImageSize } from '@/platforms/next-image';
import { Album } from '.';
import IconAlbum from '@/components/icons/IconAlbum';
export default function AlbumImageResponse({
album,
photos,
width,
height,
fontFamily,
}: {
album: Album,
photos: Photo[]
width: NextImageSize
height: number
fontFamily: string
}) {
return (
<ImageContainer solidBackground={photos.length === 0}>
<ImagePhotoGrid
{...{
photos,
width,
height,
}}
/>
<ImageCaption {...{
width,
height,
fontFamily,
icon: <IconAlbum
size={height * .07}
style={{
transform: `translateY(${height * .004}px)`,
marginRight: height * .03,
}}
/>,
title: album.title.toLocaleUpperCase(),
}} />
</ImageContainer>
);
}

View File

@ -0,0 +1,38 @@
import { Photo, PhotoDateRangePostgres } from '@/photo';
import PhotoGridContainer from '@/photo/PhotoGridContainer';
import { Album } from '.';
import AlbumHeader from './AlbumHeader';
export default function AlbumOverview({
album,
photos,
tags,
count,
dateRange,
animateOnFirstLoadOnly,
}: {
album: Album,
photos: Photo[],
tags: string[],
count: number,
dateRange?: PhotoDateRangePostgres,
animateOnFirstLoadOnly?: boolean,
}) {
return (
<PhotoGridContainer {...{
cacheKey: `album-${album.slug}`,
photos,
count,
album,
header: <AlbumHeader {...{
album,
photos,
tags,
count,
dateRange,
showAlbumMeta: true,
}} />,
animateOnFirstLoadOnly,
}} />
);
}

View File

@ -0,0 +1,26 @@
import { absolutePathForAlbum } from '@/app/path';
import { PhotoSetAttributes } from '../category';
import ShareModal from '@/share/ShareModal';
import { useAppText } from '@/i18n/state/client';
import AlbumOGTile from '@/tag/AlbumOGTile';
import { Album, shareTextForAlbum } from '.';
export default function AlbumShareModal({
album,
photos,
count,
dateRange,
}: {
album: Album
} & PhotoSetAttributes) {
const appText = useAppText();
return (
<ShareModal
pathShare={absolutePathForAlbum(album, true)}
navigatorTitle={album.title}
socialText={shareTextForAlbum(album, appText)}
>
<AlbumOGTile {...{ album, photos, count, dateRange }} />
</ShareModal>
);
};

View File

@ -0,0 +1,19 @@
import { ComponentProps } from 'react';
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { Albums } from '.';
import { convertAlbumsToAnnotatedTags } from './form';
export default function FieldsetAlbum({
albumOptions,
...props
}: {
albumOptions: Albums
} & ComponentProps<typeof FieldsetWithStatus>) {
return (
<FieldsetWithStatus
{...props}
tagOptions={convertAlbumsToAnnotatedTags(albumOptions)}
tagOptionsShouldParameterize={false}
/>
);
}

27
src/album/PhotoAlbum.tsx Normal file
View File

@ -0,0 +1,27 @@
'use client';
import { pathForAlbum } from '@/app/path';
import EntityLink, { EntityLinkExternalProps } from
'@/components/entity/EntityLink';
import IconAlbum from '@/components/icons/IconAlbum';
import { Album } from '.';
import useCategoryCounts from '@/category/useCategoryCounts';
export default function PhotoAlbum({
album,
...props
}: {
album: Album
} & EntityLinkExternalProps) {
const { getAlbumCount } = useCategoryCounts();
return (
<EntityLink
{...props}
label={album.title}
path={pathForAlbum(album)}
hoverQueryOptions={{ album }}
icon={<IconAlbum className="translate-y-[-0.5px]" />}
hoverCount={props.hoverCount ?? getAlbumCount(album)}
/>
);
}

23
src/album/actions.ts Normal file
View File

@ -0,0 +1,23 @@
'use server';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { deleteAlbum, updateAlbum } from './query';
import { revalidateAllKeysAndPaths } from '@/photo/cache';
import { redirect } from 'next/navigation';
import { PATH_ADMIN_ALBUMS } from '@/app/path';
import { convertFormDataToAlbum } from './form';
export const updateAlbumAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const album = convertFormDataToAlbum(formData);
await updateAlbum(album);
revalidateAllKeysAndPaths();
redirect(PATH_ADMIN_ALBUMS);
});
export const deleteAlbumAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const albumId = formData.get('album') as string;
await deleteAlbum(albumId);
revalidateAllKeysAndPaths();
});

16
src/album/data.ts Normal file
View File

@ -0,0 +1,16 @@
import { getPhotosMetaCached } from '@/photo/cache';
import { Album } from '.';
import { getPhotos } from '@/photo/query';
export const getPhotosAlbumDataCached = ({
album,
limit,
}: {
album: Album,
limit?: number,
}) =>
Promise.all([
getPhotos({ album, limit }),
getPhotosMetaCached({ album }),
]);

52
src/album/form.ts Normal file
View File

@ -0,0 +1,52 @@
import { AnnotatedTag, FieldSetType } from '@/photo/form';
import { Album, Albums } from '.';
import { formatCount, formatCountDescriptive } from '@/utility/string';
export const ALBUM_FORM_META: {
key: keyof Album
label?: string
type: FieldSetType
required?: boolean
readOnly?: boolean
hidden?: boolean
}[] = [
{ key: 'id', type: 'text', readOnly: true },
{ key: 'title', type: 'text', required: true },
{ key: 'slug', type: 'text', required: true, readOnly: true },
{ key: 'subhead', type: 'text' },
{ key: 'description', type: 'textarea' },
{ key: 'locationName', label: 'location name', type: 'text', hidden: true },
{ key: 'latitude', type: 'text', hidden: true },
{ key: 'longitude', type: 'text', hidden: true },
];
export const convertFormDataToAlbum = (formData: FormData): Album => {
return {
id: formData.get('id') as string,
title: formData.get('title') as string,
slug: formData.get('slug') as string,
subhead: formData.get('subhead') as string,
description: formData.get('description') as string,
locationName: formData.get('locationName') as string,
latitude: formData.get('latitude')
? parseFloat(formData.get('latitude') as string)
: undefined,
longitude: formData.get('longitude')
? parseFloat(formData.get('longitude') as string)
: undefined,
};
};
export const convertAlbumsToAnnotatedTags = (
albums: Albums = [],
): AnnotatedTag[] =>
albums
.sort((a, b) => a.album.title.localeCompare(b.album.title))
.map(({ album, count }) => ({
value: album.title,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
}));
export const getAlbumTitlesFromFormData = (formData: FormData) =>
formData.get('albums')?.toString().split(',').filter(Boolean) ?? [];

85
src/album/index.ts Normal file
View File

@ -0,0 +1,85 @@
import { absolutePathForAlbum, absolutePathForAlbumImage } from '@/app/path';
import { CategoryQueryMeta } from '@/category';
import { AppTextState } from '@/i18n/state';
import {
descriptionForPhotoSet,
Photo,
PhotoDateRangePostgres,
photoQuantityText,
} from '@/photo';
import camelcaseKeys from 'camelcase-keys';
export interface Album {
id: string
title: string
slug: string
subhead?: string
description?: string
locationName?: string
latitude?: number
longitude?: number
}
type AlbumWithMeta = {
album: Album
} & CategoryQueryMeta;
export type Albums = AlbumWithMeta[];
export type AlbumOrAlbumSlug = Album | string;
export const parseAlbumFromDb = (album: any): Album =>
camelcaseKeys(album);
export const titleForAlbum = (
album: Album,
photos:Photo[] = [],
appText: AppTextState,
explicitCount?: number,
) => [
album.title,
photoQuantityText(explicitCount ?? photos.length, appText),
].join(' ');
export const shareTextForAlbum = (
album: Album,
appText: AppTextState,
) => [
`${appText.category.album}:`,
album.title,
].join(' ');
export const descriptionForAlbumPhotos = (
photos: Photo[] = [],
appText: AppTextState,
dateBased?: boolean,
explicitCount?: number,
explicitDateRange?: PhotoDateRangePostgres,
) =>
descriptionForPhotoSet(
photos,
appText,
undefined,
dateBased,
explicitCount,
explicitDateRange,
);
export const generateMetaForAlbum = (
album: Album,
photos: Photo[],
appText: AppTextState,
explicitCount?: number,
explicitDateRange?: PhotoDateRangePostgres,
) => ({
url: absolutePathForAlbum(album),
title: titleForAlbum(album, photos, appText, explicitCount),
description: descriptionForAlbumPhotos(
photos,
appText,
true,
explicitCount,
explicitDateRange,
),
images: absolutePathForAlbumImage(album),
});

124
src/album/query.ts Normal file
View File

@ -0,0 +1,124 @@
import { safelyQuery } from '@/db/query';
import { sql } from '@/platforms/postgres';
import { Album, Albums, parseAlbumFromDb } from '.';
export const createAlbumsTable = () =>
sql`
CREATE TABLE IF NOT EXISTS albums (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
subhead TEXT,
description TEXT,
location_name VARCHAR(255),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`;
export const createAlbumPhotoTable = () =>
sql`
CREATE TABLE IF NOT EXISTS album_photo (
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
photo_id VARCHAR(8) NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
sort_order SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (album_id, photo_id)
)
`;
export const insertAlbum = (album: Omit<Album, 'id'>) =>
safelyQuery(() => sql`
INSERT INTO albums (
title,
slug,
subhead,
description,
location_name,
latitude,
longitude
) VALUES (
${album.title},
${album.slug},
${album.subhead},
${album.description},
${album.locationName},
${album.latitude},
${album.longitude}
)
RETURNING id
`.then(({ rows }) => rows[0]?.id as string)
, 'insertAlbum');
export const updateAlbum = (album: Album) =>
safelyQuery(() => sql`
UPDATE albums SET
title=${album.title},
slug=${album.slug},
subhead=${album.subhead},
description=${album.description},
location_name=${album.locationName},
latitude=${album.latitude},
longitude=${album.longitude},
updated_at=${(new Date()).toISOString()}
WHERE id=${album.id}
`, 'updateAlbum');
export const getAlbumFromSlug = (slug: string) =>
safelyQuery(() => sql<Album>`
SELECT * FROM albums WHERE slug=${slug}
`.then(({ rows }) => rows[0] ? parseAlbumFromDb(rows[0]) : undefined)
, 'getAlbum');
export const deleteAlbum = (id: string) =>
safelyQuery(() => sql`
DELETE FROM albums WHERE id=${id}
`, 'deleteAlbum');
export const getAlbumsWithMeta = () =>
safelyQuery(() => sql`
SELECT
a.*,
COALESCE(COUNT(ap.photo_id), 0) as count
FROM albums a
LEFT JOIN album_photo ap ON a.id = ap.album_id
GROUP BY a.id
ORDER BY a.created_at DESC
`.then(({ rows }): Albums => rows.map(({
count,
...album
}) => ({
album: parseAlbumFromDb(album),
count: parseInt(count, 10),
lastModified: album.updated_at as Date,
})))
, 'getAlbumsWithPhotoCounts');
export const clearPhotoAlbumIds = (photoId: string) =>
safelyQuery(() => sql`
DELETE FROM album_photo WHERE photo_id=${photoId}
`, 'clearPhotoAlbumIds');
export const addPhotoAlbumId = (photoId: string, albumId: string) =>
safelyQuery(() => sql`
INSERT INTO album_photo (album_id, photo_id) VALUES (${albumId}, ${photoId})
ON CONFLICT (album_id, photo_id) DO NOTHING
`, 'updateAlbumPhoto');
export const getAlbumTitlesForPhoto = (photoId: string) =>
safelyQuery(() => sql<{ title: string }>`
SELECT a.title FROM albums a
JOIN album_photo ap ON a.id = ap.album_id
WHERE ap.photo_id=${photoId}
`.then(({ rows }) => rows.map(({ title }) => title))
, 'getAlbumTitlesForPhoto');
export const getTagsForAlbum = (albumId: string) =>
safelyQuery(() => sql`
SELECT DISTINCT unnest(p.tags) as tag
FROM photos p
LEFT JOIN album_photo ap ON p.id = ap.photo_id
WHERE album_id=${albumId}
`.then(({ rows }) => rows.map(({ tag }) => tag))
, 'getTagsForAlbum');

30
src/album/server.ts Normal file
View File

@ -0,0 +1,30 @@
import { parameterize } from '@/utility/string';
import {
addPhotoAlbumId,
clearPhotoAlbumIds,
getAlbumsWithMeta,
insertAlbum,
} from './query';
const createAlbumsAndGetIds = async (titles: string[]) => {
const albums = await getAlbumsWithMeta();
return Promise.all(titles.map(async title => {
const album = albums.find(({ album }) => album.title === title);
if (album) {
return album.album.id;
} else {
const albumInsert = { title, slug: parameterize(title) };
return insertAlbum(albumInsert);
}
}));
};
export const addAlbumTitlesToPhoto = async (
albumTitles: string[],
photoId: string,
shouldClearPhotoAlbumIds = true,
) => {
const albumIds = await createAlbumsAndGetIds(albumTitles);
if (shouldClearPhotoAlbumIds) { await clearPhotoAlbumIds(photoId); }
await Promise.all(albumIds.map(albumId => addPhotoAlbumId(photoId, albumId)));
};

View File

@ -1,8 +1,8 @@
import { NAV_TITLE } from '@/app/config'; import { NAV_TITLE } from '@/app/config';
import { Photo } from '../photo'; import { Photo } from '../photo';
import ImageCaption from './components/ImageCaption'; import ImageCaption from '@/image-response/components/ImageCaption';
import ImageContainer from './components/ImageContainer'; import ImageContainer from '@/image-response/components/ImageContainer';
import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
import { NextImageSize } from '@/platforms/next-image'; import { NextImageSize } from '@/platforms/next-image';
export default function HomeImageResponse({ export default function HomeImageResponse({

View File

@ -1,7 +1,7 @@
import { Photo } from '../photo'; import { Photo } from '../photo';
import IconFull from '@/components/icons/IconFull'; import IconFull from '@/components/icons/IconFull';
import IconGrid from '@/components/icons/IconGrid'; import IconGrid from '@/components/icons/IconGrid';
import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImagePhotoGrid from '../image-response/components/ImagePhotoGrid';
import { NextImageSize } from '@/platforms/next-image'; import { NextImageSize } from '@/platforms/next-image';
export default function TemplateImageResponse({ export default function TemplateImageResponse({

View File

@ -274,6 +274,8 @@ export const SHOW_CAMERAS =
CATEGORY_VISIBILITY.includes('cameras'); CATEGORY_VISIBILITY.includes('cameras');
export const SHOW_LENSES = export const SHOW_LENSES =
CATEGORY_VISIBILITY.includes('lenses'); CATEGORY_VISIBILITY.includes('lenses');
export const SHOW_ALBUMS =
CATEGORY_VISIBILITY.includes('albums');
export const SHOW_TAGS = export const SHOW_TAGS =
CATEGORY_VISIBILITY.includes('tags'); CATEGORY_VISIBILITY.includes('tags');
export const SHOW_RECIPES = export const SHOW_RECIPES =

View File

@ -5,6 +5,7 @@ import { Camera } from '@/camera';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { TAG_PRIVATE } from '@/tag'; import { TAG_PRIVATE } from '@/tag';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
import { Album, AlbumOrAlbumSlug } from '@/album';
// Core // Core
export const PATH_ROOT = '/'; export const PATH_ROOT = '/';
@ -43,6 +44,7 @@ export const PATH_FEED_JSON = '/feed.json';
export const PREFIX_PHOTO = '/p'; export const PREFIX_PHOTO = '/p';
export const PREFIX_CAMERA = '/shot-on'; export const PREFIX_CAMERA = '/shot-on';
export const PREFIX_LENS = '/lens'; export const PREFIX_LENS = '/lens';
export const PREFIX_ALBUM = '/album';
export const PREFIX_TAG = '/tag'; export const PREFIX_TAG = '/tag';
export const PREFIX_RECIPE = '/recipe'; export const PREFIX_RECIPE = '/recipe';
export const PREFIX_FILM = '/film'; export const PREFIX_FILM = '/film';
@ -54,6 +56,7 @@ export const PREFIX_RECENTS = '/recents';
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`; const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`; const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`; const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`;
const PATH_ALBUM_DYNAMIC = `${PREFIX_ALBUM}/[album]`;
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`; const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`; const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`;
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`; const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
@ -65,6 +68,7 @@ const PATH_RECENTS_DYNAMIC = `${PREFIX_RECENTS}/[photoId]`;
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_PHOTOS_UPDATES = `${PATH_ADMIN_PHOTOS}/updates`; export const PATH_ADMIN_PHOTOS_UPDATES = `${PATH_ADMIN_PHOTOS}/updates`;
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_ALBUMS = `${PATH_ADMIN}/albums`;
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`; export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`; export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
@ -95,6 +99,7 @@ export const PATHS_ADMIN = [
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_PHOTOS_UPDATES, PATH_ADMIN_PHOTOS_UPDATES,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
PATH_ADMIN_ALBUMS,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_RECIPES, PATH_ADMIN_RECIPES,
PATH_ADMIN_INSIGHTS, PATH_ADMIN_INSIGHTS,
@ -111,6 +116,7 @@ export const PATHS_TO_CACHE = [
PATH_PHOTO_DYNAMIC, PATH_PHOTO_DYNAMIC,
PATH_CAMERA_DYNAMIC, PATH_CAMERA_DYNAMIC,
PATH_LENS_DYNAMIC, PATH_LENS_DYNAMIC,
PATH_ALBUM_DYNAMIC,
PATH_TAG_DYNAMIC, PATH_TAG_DYNAMIC,
PATH_FILM_DYNAMIC, PATH_FILM_DYNAMIC,
PATH_FOCAL_LENGTH_DYNAMIC, PATH_FOCAL_LENGTH_DYNAMIC,
@ -131,6 +137,9 @@ export const pathForAdminUploadUrl = (url: string, title?: string) =>
export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) => export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) =>
`${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`; `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`;
export const pathForAdminAlbumEdit = (album: Album) =>
`${PATH_ADMIN_ALBUMS}/${album.slug}/${EDIT}`;
export const pathForAdminTagEdit = (tag: string) => export const pathForAdminTagEdit = (tag: string) =>
`${PATH_ADMIN_TAGS}/${tag}/${EDIT}`; `${PATH_ADMIN_TAGS}/${tag}/${EDIT}`;
@ -148,6 +157,7 @@ export const pathForPhoto = ({
year, year,
camera, camera,
lens, lens,
album,
tag, tag,
film, film,
focal, focal,
@ -165,6 +175,8 @@ export const pathForPhoto = ({
prefix = pathForCamera(camera); prefix = pathForCamera(camera);
} else if (lens) { } else if (lens) {
prefix = pathForLens(lens); prefix = pathForLens(lens);
} else if (album) {
prefix = pathForAlbum(album);
} else if (tag) { } else if (tag) {
prefix = pathForTag(tag); prefix = pathForTag(tag);
} else if (recipe) { } else if (recipe) {
@ -178,6 +190,9 @@ export const pathForPhoto = ({
return `${prefix}/${getPhotoId(photo)}`; return `${prefix}/${getPhotoId(photo)}`;
}; };
export const pathForYear = (year: string) =>
`${PREFIX_YEAR}/${year}`;
export const pathForCamera = ({ make, model }: Camera) => export const pathForCamera = ({ make, model }: Camera) =>
`${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`; `${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`;
@ -186,6 +201,9 @@ export const pathForLens = ({ make, model }: Lens) =>
? `${PREFIX_LENS}/${parameterize(make)}/${parameterize(model)}` ? `${PREFIX_LENS}/${parameterize(make)}/${parameterize(model)}`
: `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`; : `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`;
export const pathForAlbum = (album: AlbumOrAlbumSlug) =>
`${PREFIX_ALBUM}/${typeof album === 'string' ? album : album.slug}`;
export const pathForTag = (tag: string) => export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`; `${PREFIX_TAG}/${tag}`;
@ -198,9 +216,6 @@ export const pathForFilm = (film: string) =>
export const pathForFocalLength = (focal: number) => export const pathForFocalLength = (focal: number) =>
`${PREFIX_FOCAL_LENGTH}/${focal}mm`; `${PREFIX_FOCAL_LENGTH}/${focal}mm`;
export const pathForYear = (year: string) =>
`${PREFIX_YEAR}/${year}`;
// Image paths // Image paths
const pathForImage = (path: string) => const pathForImage = (path: string) =>
`${path}/${IMAGE}`; `${path}/${IMAGE}`;
@ -214,6 +229,9 @@ export const pathForCameraImage = (camera: Camera) =>
export const pathForLensImage = (lens: Lens) => export const pathForLensImage = (lens: Lens) =>
pathForImage(pathForLens(lens)); pathForImage(pathForLens(lens));
export const pathForAlbumImage = (album: Album) =>
pathForImage(pathForAlbum(album));
export const pathForTagImage = (tag: string) => export const pathForTagImage = (tag: string) =>
pathForImage(pathForTag(tag)); pathForImage(pathForTag(tag));
@ -260,6 +278,9 @@ export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
export const absolutePathForLens= (lens: Lens, share?: boolean) => export const absolutePathForLens= (lens: Lens, share?: boolean) =>
`${getBaseUrl(share)}${pathForLens(lens)}`; `${getBaseUrl(share)}${pathForLens(lens)}`;
export const absolutePathForAlbum = (album: Album, share?: boolean) =>
`${getBaseUrl(share)}${pathForAlbum(album)}`;
export const absolutePathForTag = (tag: string, share?: boolean) => export const absolutePathForTag = (tag: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForTag(tag)}`; `${getBaseUrl(share)}${pathForTag(tag)}`;
@ -279,31 +300,34 @@ export const absolutePathForRecents = (share?: boolean) =>
`${getBaseUrl(share)}${PREFIX_RECENTS}`; `${getBaseUrl(share)}${PREFIX_RECENTS}`;
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) => export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
`${getBaseUrl()}${pathForPhotoImage(photo)}`; `${absolutePathForPhoto({ photo })}/${IMAGE}`;
export const absolutePathForCameraImage= (camera: Camera) => export const absolutePathForCameraImage= (camera: Camera) =>
`${getBaseUrl()}${pathForCameraImage(camera)}`; `${absolutePathForCamera(camera)}/${IMAGE}`;
export const absolutePathForLensImage= (lens: Lens) => export const absolutePathForLensImage= (lens: Lens) =>
`${getBaseUrl()}${pathForLensImage(lens)}`; `${absolutePathForLens(lens)}/${IMAGE}`;
export const absolutePathForAlbumImage = (album: Album) =>
`${absolutePathForAlbum(album)}/${IMAGE}`;
export const absolutePathForTagImage = (tag: string) => export const absolutePathForTagImage = (tag: string) =>
`${getBaseUrl()}${pathForTagImage(tag)}`; `${absolutePathForTag(tag)}/${IMAGE}`;
export const absolutePathForRecipeImage = (recipe: string) => export const absolutePathForRecipeImage = (recipe: string) =>
`${getBaseUrl()}${pathForRecipeImage(recipe)}`; `${absolutePathForRecipe(recipe)}/${IMAGE}`;
export const absolutePathForFilmImage = (film: string) => export const absolutePathForFilmImage = (film: string) =>
`${getBaseUrl()}${pathForFilmImage(film)}`; `${absolutePathForFilm(film)}/${IMAGE}`;
export const absolutePathForFocalLengthImage = (focal: number) => export const absolutePathForFocalLengthImage = (focal: number) =>
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`; `${absolutePathForFocalLength(focal)}/${IMAGE}`;
export const absolutePathForYearImage = (year: string, share?: boolean) => export const absolutePathForYearImage = (year: string) =>
`${getBaseUrl(share)}${pathForYearImage(year)}`; `${absolutePathForYear(year)}/${IMAGE}`;
export const absolutePathForRecentsImage = (share?: boolean) => export const absolutePathForRecentsImage = () =>
`${getBaseUrl(share)}${pathForRecentsImage()}`; `${absolutePathForRecents()}/${IMAGE}`;
// p/[photoId] // p/[photoId]
export const isPathPhoto = (pathname = '') => export const isPathPhoto = (pathname = '') =>
@ -341,6 +365,14 @@ export const isPathLens = (pathname = '') =>
export const isPathLensPhoto = (pathname = '') => export const isPathLensPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_LENS}/[^/]+/[^/]+/[^/]+/?$`).test(pathname); new RegExp(`^${PREFIX_LENS}/[^/]+/[^/]+/[^/]+/?$`).test(pathname);
// album/[album]
export const isPathAlbum = (pathname = '') =>
new RegExp(`^${PREFIX_ALBUM}/[^/]+/?$`).test(pathname);
// album/[album]/[photoId]
export const isPathAlbumPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_ALBUM}/[^/]+/[^/]+/?$`).test(pathname);
// tag/[tag] // tag/[tag]
export const isPathTag = (pathname = '') => export const isPathTag = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname); new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);
@ -417,9 +449,12 @@ export const isPathProtected = (pathname?: string) =>
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) || checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
checkPathPrefix(pathname, PATH_OG); checkPathPrefix(pathname, PATH_OG);
export const getPathComponents = (pathname = ''): { export const getPathComponents = (
pathname = '',
): (Omit<PhotoSetCategory, 'album'> & {
album?: string
photoId?: string photoId?: string
} & PhotoSetCategory => { }) => {
const photoIdFromPhoto = pathname.match( const photoIdFromPhoto = pathname.match(
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
const photoIdFromCamera = pathname.match( const photoIdFromCamera = pathname.match(
@ -438,6 +473,8 @@ export const getPathComponents = (pathname = ''): {
new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1];
const photoIdFromRecents = pathname.match( const photoIdFromRecents = pathname.match(
new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[1];
const album = pathname.match(
new RegExp(`^${PREFIX_ALBUM}/([^/]+)`))?.[1];
const tag = pathname.match( const tag = pathname.match(
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
const film = pathname.match( const film = pathname.match(
@ -464,6 +501,7 @@ export const getPathComponents = (pathname = ''): {
photoIdFromYear || photoIdFromYear ||
photoIdFromRecents photoIdFromRecents
), ),
album,
tag, tag,
camera, camera,
film, film,
@ -480,6 +518,7 @@ export const getEscapePath = (pathname?: string) => {
year, year,
camera, camera,
lens, lens,
album,
tag, tag,
recipe, recipe,
film, film,
@ -506,6 +545,8 @@ export const getEscapePath = (pathname?: string) => {
return pathForCamera(camera); return pathForCamera(camera);
} else if (lens && isPathLensPhoto(pathname)) { } else if (lens && isPathLensPhoto(pathname)) {
return pathForLens(lens); return pathForLens(lens);
} else if (album && isPathAlbumPhoto(pathname)) {
return pathForAlbum(album);
} else if (tag && isPathTagPhoto(pathname)) { } else if (tag && isPathTagPhoto(pathname)) {
return pathForTag(tag); return pathForTag(tag);
} else if (recipe && isPathRecipePhoto(pathname)) { } else if (recipe && isPathRecipePhoto(pathname)) {

View File

@ -8,8 +8,8 @@ import {
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES, STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES,
STATICALLY_OPTIMIZED_PHOTOS, STATICALLY_OPTIMIZED_PHOTOS,
} from '@/app/config'; } from '@/app/config';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db'; import { GENERATE_STATIC_PARAMS_LIMIT } from '@/db';
import { getPublicPhotoIds } from '@/photo/db/query'; import { getPublicPhotoIds } from '@/photo/query';
import { depluralize, pluralize } from '@/utility/string'; import { depluralize, pluralize } from '@/utility/string';
type StaticOutput = 'page' | 'image'; type StaticOutput = 'page' | 'image';

View File

@ -1,4 +1,4 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import { Camera, cameraFromPhoto } from '.'; import { Camera, cameraFromPhoto } from '.';
import PhotoCamera from './PhotoCamera'; import PhotoCamera from './PhotoCamera';
@ -19,7 +19,7 @@ export default async function CameraHeader({
selectedPhoto?: Photo selectedPhoto?: Photo
indexNumber?: number indexNumber?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
}) { }) {
const appText = await getAppText(); const appText = await getAppText();
const camera = cameraFromPhoto(photos[0], cameraProp); const camera = cameraFromPhoto(photos[0], cameraProp);
@ -30,7 +30,7 @@ export default async function CameraHeader({
entity={<PhotoCamera entity={<PhotoCamera
{...{ camera }} {...{ camera }}
contrast="high" contrast="high"
showHover={false} hoverType="none"
/>} />}
entityDescription={ entityDescription={
descriptionForCameraPhotos( descriptionForCameraPhotos(

View File

@ -1,7 +1,7 @@
import { Photo } from '../photo'; import { Photo } from '../photo';
import ImageCaption from './components/ImageCaption'; import ImageCaption from '@/image-response/components/ImageCaption';
import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer'; import ImageContainer from '@/image-response/components/ImageContainer';
import { import {
Camera, Camera,
cameraFromPhoto, cameraFromPhoto,

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import { pathForCamera, pathForCameraImage } from '@/app/path'; import { pathForCamera, pathForCameraImage } from '@/app/path';
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { Camera } from '.'; import { Camera } from '.';
@ -17,7 +17,7 @@ export default function CameraOGTile({
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
} & OGTilePropsCore) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (

View File

@ -1,4 +1,4 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import { Camera, createCameraKey } from '.'; import { Camera, createCameraKey } from '.';
import CameraHeader from './CameraHeader'; import CameraHeader from './CameraHeader';
import PhotoGridContainer from '@/photo/PhotoGridContainer'; import PhotoGridContainer from '@/photo/PhotoGridContainer';
@ -13,7 +13,7 @@ export default function CameraOverview({
camera: Camera, camera: Camera,
photos: Photo[], photos: Photo[],
count: number, count: number,
dateRange?: PhotoDateRange, dateRange?: PhotoDateRangePostgres,
animateOnFirstLoadOnly?: boolean, animateOnFirstLoadOnly?: boolean,
}) { }) {
return ( return (

View File

@ -8,6 +8,7 @@ import EntityLink, {
} from '@/components/entity/EntityLink'; } from '@/components/entity/EntityLink';
import IconCamera from '@/components/icons/IconCamera'; import IconCamera from '@/components/icons/IconCamera';
import { isCameraApple } from '@/platforms/apple'; import { isCameraApple } from '@/platforms/apple';
import useCategoryCounts from '@/category/useCategoryCounts';
export default function PhotoCamera({ export default function PhotoCamera({
camera, camera,
@ -17,6 +18,8 @@ export default function PhotoCamera({
camera: Camera camera: Camera
hideAppleIcon?: boolean hideAppleIcon?: boolean
} & EntityLinkExternalProps) { } & EntityLinkExternalProps) {
const { getCameraCount } = useCategoryCounts();
const isApple = isCameraApple(camera); const isApple = isCameraApple(camera);
const showAppleIcon = !hideAppleIcon && isApple; const showAppleIcon = !hideAppleIcon && isApple;
@ -25,7 +28,7 @@ export default function PhotoCamera({
{...props} {...props}
label={formatCameraText(camera)} label={formatCameraText(camera)}
path={pathForCamera(camera)} path={pathForCamera(camera)}
hoverPhotoQueryOptions={{ camera }} hoverQueryOptions={{ camera }}
icon={showAppleIcon icon={showAppleIcon
? <AiFillApple ? <AiFillApple
title="Apple" title="Apple"
@ -36,6 +39,7 @@ export default function PhotoCamera({
size={15} size={15}
className="translate-x-[-0.5px] translate-y-[-0.5px]" className="translate-x-[-0.5px] translate-y-[-0.5px]"
/>} />}
hoverCount={props.hoverCount ?? getCameraCount(camera)}
/> />
); );
} }

View File

@ -1,6 +1,6 @@
import { import {
Photo, Photo,
PhotoDateRange, PhotoDateRangePostgres,
descriptionForPhotoSet, descriptionForPhotoSet,
photoQuantityText, photoQuantityText,
} from '@/photo'; } from '@/photo';
@ -41,7 +41,7 @@ export const descriptionForCameraPhotos = (
appText: AppTextState, appText: AppTextState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRangePostgres,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
@ -57,7 +57,7 @@ export const generateMetaForCamera = (
photos: Photo[], photos: Photo[],
appText: AppTextState, appText: AppTextState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRangePostgres,
) => ({ ) => ({
url: absolutePathForCamera(camera), url: absolutePathForCamera(camera),
title: titleForCamera(camera, photos, appText, explicitCount), title: titleForCamera(camera, photos, appText, explicitCount),

View File

@ -7,7 +7,7 @@ import {
getUniqueRecipes, getUniqueRecipes,
getUniqueTags, getUniqueTags,
getUniqueYears, getUniqueYears,
} from '@/photo/db/query'; } from '@/photo/query';
import { import {
SHOW_FILMS, SHOW_FILMS,
SHOW_FOCAL_LENGTHS, SHOW_FOCAL_LENGTHS,
@ -17,11 +17,13 @@ import {
SHOW_TAGS, SHOW_TAGS,
SHOW_YEARS, SHOW_YEARS,
SHOW_RECENTS, SHOW_RECENTS,
SHOW_ALBUMS,
} from '@/app/config'; } from '@/app/config';
import { createLensKey } from '@/lens'; import { createLensKey } from '@/lens';
import { sortTagsByCount } from '@/tag'; import { sortTagsByCount } from '@/tag';
import { sortCategoriesByCount } from '@/category'; import { sortCategoriesByCount } from '@/category';
import { sortFocalLengths } from '@/focal'; import { sortFocalLengths } from '@/focal';
import { getAlbumsWithMeta } from '@/album/query';
type CategoryData = Awaited<ReturnType<typeof getDataForCategories>>; type CategoryData = Awaited<ReturnType<typeof getDataForCategories>>;
@ -34,6 +36,7 @@ export const NULL_CATEGORY_DATA: CategoryData = {
recipes: [], recipes: [],
films: [], films: [],
focalLengths: [], focalLengths: [],
albums: [],
}; };
export const getDataForCategories = () => Promise.all([ export const getDataForCategories = () => Promise.all([
@ -80,6 +83,10 @@ export const getDataForCategories = () => Promise.all([
.then(sortFocalLengths) .then(sortFocalLengths)
.catch(() => []) .catch(() => [])
: undefined, : undefined,
SHOW_ALBUMS
? getAlbumsWithMeta()
.catch(() => [])
: undefined,
]).then(([ ]).then(([
recents = [], recents = [],
years = [], years = [],
@ -89,6 +96,7 @@ export const getDataForCategories = () => Promise.all([
recipes = [], recipes = [],
films = [], films = [],
focalLengths = [], focalLengths = [],
albums = [],
]) => ({ ]) => ({
recents, recents,
years, years,
@ -98,6 +106,7 @@ export const getDataForCategories = () => Promise.all([
recipes, recipes,
films, films,
focalLengths, focalLengths,
albums,
})); }));
export const getCountsForCategories = async () => { export const getCountsForCategories = async () => {
@ -106,6 +115,7 @@ export const getCountsForCategories = async () => {
years, years,
cameras, cameras,
lenses, lenses,
albums,
tags, tags,
recipes, recipes,
films, films,
@ -120,6 +130,10 @@ export const getCountsForCategories = async () => {
acc[year.year] = year.count; acc[year.year] = year.count;
return acc; return acc;
}, {} as Record<string, number>), }, {} as Record<string, number>),
albums: albums.reduce((acc, { album, count }) => {
acc[album.slug] = count;
return acc;
}, {} as Record<string, number>),
cameras: cameras.reduce((acc, camera) => { cameras: cameras.reduce((acc, camera) => {
acc[camera.cameraKey] = camera.count; acc[camera.cameraKey] = camera.count;
return acc; return acc;

View File

@ -1,4 +1,4 @@
import { Photo, PhotoDateRange } from '../photo'; import { Photo, PhotoDateRangePostgres } from '../photo';
import { Camera, Cameras } from '@/camera'; import { Camera, Cameras } from '@/camera';
import { Films } from '@/film'; import { Films } from '@/film';
import { Lens, Lenses } from '@/lens'; import { Lens, Lenses } from '@/lens';
@ -6,14 +6,16 @@ import { Tags } from '@/tag';
import { FocalLengths } from '@/focal'; import { FocalLengths } from '@/focal';
import { Recipes } from '@/recipe'; import { Recipes } from '@/recipe';
import { Recents } from '@/recents'; import { Recents } from '@/recents';
import { Years } from '@/years'; import { Years } from '@/year';
import { parseCommaSeparatedKeyString } from '@/utility/key'; import { parseCommaSeparatedKeyString } from '@/utility/key';
import { Album, Albums } from '@/album';
export const CATEGORY_KEYS = [ export const CATEGORY_KEYS = [
'recents', 'recents',
'years', 'years',
'cameras', 'cameras',
'lenses', 'lenses',
'albums',
'tags', 'tags',
'recipes', 'recipes',
'films', 'films',
@ -26,6 +28,7 @@ export type CategoryKeys = CategoryKey[];
export const DEFAULT_CATEGORY_KEYS: CategoryKeys = [ export const DEFAULT_CATEGORY_KEYS: CategoryKeys = [
'recents', 'recents',
'albums',
'tags', 'tags',
'cameras', 'cameras',
'lenses', 'lenses',
@ -55,6 +58,7 @@ export interface PhotoSetCategory {
year?: string year?: string
camera?: Camera camera?: Camera
lens?: Lens lens?: Lens
album?: Album
tag?: string tag?: string
recipe?: string recipe?: string
film?: string film?: string
@ -62,20 +66,21 @@ export interface PhotoSetCategory {
} }
export interface PhotoSetCategories { export interface PhotoSetCategories {
recents: Recents
years: Years
cameras: Cameras cameras: Cameras
lenses: Lenses lenses: Lenses
albums: Albums
tags: Tags tags: Tags
recipes: Recipes recipes: Recipes
films: Films films: Films
focalLengths: FocalLengths focalLengths: FocalLengths
years: Years
recents: Recents
} }
export interface PhotoSetAttributes { export interface PhotoSetAttributes {
photos: Photo[] photos: Photo[]
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
} }
export const sortCategoryByCount = ( export const sortCategoryByCount = (

View File

@ -2,10 +2,18 @@ import { createCameraKey, Camera } from '@/camera';
import { createLensKey, Lens } from '@/lens'; import { createLensKey, Lens } from '@/lens';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { Album } from '@/album';
export default function useCategoryCounts() { export default function useCategoryCounts() {
const { categoriesWithCounts } = useAppState(); const { categoriesWithCounts } = useAppState();
const recentsCount = categoriesWithCounts?.recents[0] ?? 0;
const getYearsCount = useCallback((year: string) => {
const yearCounts = categoriesWithCounts?.years ?? {};
return yearCounts[year];
}, [categoriesWithCounts]);
const getCameraCount = useCallback((camera: Camera) => { const getCameraCount = useCallback((camera: Camera) => {
const cameraCounts = categoriesWithCounts?.cameras ?? {}; const cameraCounts = categoriesWithCounts?.cameras ?? {};
return cameraCounts[createCameraKey(camera)]; return cameraCounts[createCameraKey(camera)];
@ -16,6 +24,11 @@ export default function useCategoryCounts() {
return lensCounts[createLensKey(lens)]; return lensCounts[createLensKey(lens)];
}, [categoriesWithCounts]); }, [categoriesWithCounts]);
const getAlbumCount = useCallback((album: Album) => {
const albumCounts = categoriesWithCounts?.albums ?? {};
return albumCounts[album.slug];
}, [categoriesWithCounts]);
const getTagCount = useCallback((tag: string) => { const getTagCount = useCallback((tag: string) => {
const tagCounts = categoriesWithCounts?.tags ?? {}; const tagCounts = categoriesWithCounts?.tags ?? {};
return tagCounts[tag]; return tagCounts[tag];
@ -37,8 +50,11 @@ export default function useCategoryCounts() {
}, [categoriesWithCounts]); }, [categoriesWithCounts]);
return { return {
recentsCount,
getYearsCount,
getCameraCount, getCameraCount,
getLensCount, getLensCount,
getAlbumCount,
getTagCount, getTagCount,
getRecipeCount, getRecipeCount,
getFilmCount, getFilmCount,

View File

@ -1,46 +0,0 @@
import { Photo } from '@/photo';
import useCategoryCounts from './useCategoryCounts';
import { cameraFromPhoto } from '@/camera';
import { lensFromPhoto } from '@/lens';
import { useMemo } from 'react';
export default function useCategoryCountsForPhoto(photo: Photo) {
const {
getCameraCount,
getLensCount,
getTagCount,
getRecipeCount,
getFilmCount,
getFocalLengthCount,
} = useCategoryCounts();
const camera = cameraFromPhoto(photo);
const lens = lensFromPhoto(photo);
const categoryCounts = useMemo(() => ({
cameraCount: getCameraCount(camera),
lensCount: getLensCount(lens),
tagCounts: photo.tags.reduce((acc, tag) => {
acc[tag] = getTagCount(tag);
return acc;
}, {} as Record<string, number>),
recipeCount: photo.recipeTitle ? getRecipeCount(photo.recipeTitle) : 0,
filmCount: photo.film ? getFilmCount(photo.film) : 0,
focalCount: photo.focalLength ? getFocalLengthCount(photo.focalLength) : 0,
}), [
getCameraCount,
getLensCount,
getRecipeCount,
getFilmCount,
getFocalLengthCount,
getTagCount,
camera,
lens,
photo.tags,
photo.recipeTitle,
photo.film,
photo.focalLength,
]);
return categoryCounts;
}

View File

@ -23,6 +23,7 @@ import {
PATH_FULL_INFERRED, PATH_FULL_INFERRED,
PATH_GRID_INFERRED, PATH_GRID_INFERRED,
PATH_SIGN_IN, PATH_SIGN_IN,
pathForAlbum,
pathForCamera, pathForCamera,
pathForFilm, pathForFilm,
pathForFocalLength, pathForFocalLength,
@ -94,6 +95,7 @@ import IconCheck from '@/components/icons/IconCheck';
import { getSortStateFromPath } from '@/photo/sort/path'; import { getSortStateFromPath } from '@/photo/sort/path';
import IconSort from '@/components/icons/IconSort'; import IconSort from '@/components/icons/IconSort';
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
import IconAlbum from '@/components/icons/IconAlbum';
const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -140,6 +142,7 @@ export default function CommandKClient({
years: _years, years: _years,
cameras, cameras,
lenses, lenses,
albums,
tags: _tags, tags: _tags,
recipes, recipes,
films, films,
@ -397,6 +400,16 @@ export default function CommandKClient({
path: pathForLens(lens), path: pathForLens(lens),
})), })),
}; };
case 'albums': return {
heading: appText.category.albumPlural,
accessory: <IconAlbum size={14} />,
items: albums.map(({ album, count }) => ({
label: album.title,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForAlbum(album),
})),
};
case 'tags': return { case 'tags': return {
heading: appText.category.tagPlural, heading: appText.category.tagPlural,
accessory: <IconTag accessory: <IconTag
@ -466,6 +479,7 @@ export default function CommandKClient({
years, years,
cameras, cameras,
lenses, lenses,
albums,
tags, tags,
recipes, recipes,
films, films,

View File

@ -31,6 +31,7 @@ export default function FieldsetWithStatus({
tagOptions, tagOptions,
tagOptionsLimit, tagOptionsLimit,
tagOptionsLimitValidationMessage, tagOptionsLimitValidationMessage,
tagOptionsShouldParameterize,
tagOptionsDefaultIcon, tagOptionsDefaultIcon,
placeholder, placeholder,
loading, loading,
@ -62,6 +63,7 @@ export default function FieldsetWithStatus({
tagOptions?: AnnotatedTag[] tagOptions?: AnnotatedTag[]
tagOptionsLimit?: number tagOptionsLimit?: number
tagOptionsLimitValidationMessage?: string tagOptionsLimitValidationMessage?: string
tagOptionsShouldParameterize?: boolean
tagOptionsDefaultIcon?: ReactNode tagOptionsDefaultIcon?: ReactNode
placeholder?: string placeholder?: string
loading?: boolean loading?: boolean
@ -210,6 +212,7 @@ export default function FieldsetWithStatus({
placeholder={placeholder} placeholder={placeholder}
limit={tagOptionsLimit} limit={tagOptionsLimit}
limitValidationMessage={tagOptionsLimitValidationMessage} limitValidationMessage={tagOptionsLimitValidationMessage}
shouldParameterize={tagOptionsShouldParameterize}
/> />
: type === 'textarea' : type === 'textarea'
? <textarea ? <textarea

View File

@ -1,3 +1,5 @@
'use client';
import { HTMLAttributes, RefObject, useRef } from 'react'; import { HTMLAttributes, RefObject, useRef } from 'react';
import useMaskedScroll from './useMaskedScroll'; import useMaskedScroll from './useMaskedScroll';

View File

@ -30,6 +30,7 @@ export default function TagInput({
placeholder, placeholder,
limit, limit,
limitValidationMessage, limitValidationMessage,
shouldParameterize,
}: { }: {
id?: string id?: string
name: string name: string
@ -43,6 +44,7 @@ export default function TagInput({
placeholder?: string placeholder?: string
limit?: number limit?: number
limitValidationMessage?: string limitValidationMessage?: string
shouldParameterize?: boolean
}) { }) {
const behaveAsDropdown = limit === 1; const behaveAsDropdown = limit === 1;
@ -59,8 +61,8 @@ export default function TagInput({
, [options]); , [options]);
const selectedOptions = useMemo(() => const selectedOptions = useMemo(() =>
convertStringToArray(value) ?? [] convertStringToArray(value, shouldParameterize) ?? []
, [value]); , [value, shouldParameterize]);
const hasReachedLimit = useMemo(() => const hasReachedLimit = useMemo(() =>
limit !== undefined && limit !== undefined &&
@ -68,14 +70,30 @@ export default function TagInput({
!behaveAsDropdown !behaveAsDropdown
, [limit, behaveAsDropdown, selectedOptions]); , [limit, behaveAsDropdown, selectedOptions]);
const inputTextFormatted = parameterize(inputText); const inputTextFormatted = shouldParameterize
const isInputTextUnique = ? parameterize(inputText)
inputTextFormatted && : inputText.trim();
const isInputTextUnique = useMemo(() => {
if (shouldParameterize) {
// Check already-parameterized values
return inputTextFormatted &&
!optionValues.includes(inputTextFormatted) && !optionValues.includes(inputTextFormatted) &&
!selectedOptions.includes(inputTextFormatted); !selectedOptions.includes(inputTextFormatted);
} else {
// Parameterize for check only
const inputTextParameterized = parameterize(inputTextFormatted);
return inputTextFormatted &&
!optionValues
.map(value => parameterize(value))
.includes((inputTextParameterized)) &&
!selectedOptions
.map(value => parameterize(value))
.includes(inputTextParameterized);
}
}, [shouldParameterize, inputTextFormatted, optionValues, selectedOptions]);
const optionsFiltered = useMemo<AnnotatedTag[]>(() => hasReachedLimit const optionsFiltered = useMemo<AnnotatedTag[]>(() => hasReachedLimit
? [{ value: limitValidationMessage ?? `Tag limit reached (${limit})` }] ? [{ value: limitValidationMessage ?? `Limit reached (${limit})` }]
: (isInputTextUnique : (isInputTextUnique
? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }] ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
: [] : []
@ -84,7 +102,9 @@ export default function TagInput({
!selectedOptions.includes(value) && !selectedOptions.includes(value) &&
( (
!inputTextFormatted || !inputTextFormatted ||
value.includes(inputTextFormatted) (shouldParameterize
? value.includes(inputTextFormatted)
: (parameterize(value)).includes(parameterize(inputTextFormatted)))
))) )))
, [ , [
hasReachedLimit, hasReachedLimit,
@ -94,6 +114,7 @@ export default function TagInput({
limitValidationMessage, limitValidationMessage,
options, options,
selectedOptions, selectedOptions,
shouldParameterize,
]); ]);
const hideMenu = useCallback((shouldBlurInput?: boolean) => { const hideMenu = useCallback((shouldBlurInput?: boolean) => {
@ -110,7 +131,9 @@ export default function TagInput({
.map(option => option.startsWith(CREATE_LABEL) .map(option => option.startsWith(CREATE_LABEL)
? option.match(new RegExp(`^${CREATE_LABEL} "(.+)"$`))?.[1] ?? option ? option.match(new RegExp(`^${CREATE_LABEL} "(.+)"$`))?.[1] ?? option
: option) : option)
.map(option => parameterize(option)) .map(option => shouldParameterize
? parameterize(option)
: option)
.filter(option => !selectedOptions.includes(option)); .filter(option => !selectedOptions.includes(option));
if (optionsToAdd.length > 0) { if (optionsToAdd.length > 0) {
@ -136,14 +159,22 @@ export default function TagInput({
} else { } else {
inputRef.current?.focus(); inputRef.current?.focus();
} }
}, [limit, behaveAsDropdown, selectedOptions, onChange, hideMenu]); }, [
limit,
behaveAsDropdown,
selectedOptions,
shouldParameterize,
onChange,
hideMenu,
]);
const removeOption = useCallback((option: string) => { const removeOption = useCallback((option: string) => {
onChange?.(selectedOptions.filter(o => onChange?.(selectedOptions
o !== parameterize(option)).join(',')); .filter(o => o !== (shouldParameterize ? parameterize(option) : option))
.join(','));
setSelectedOptionIndex(undefined); setSelectedOptionIndex(undefined);
inputRef.current?.focus(); inputRef.current?.focus();
}, [onChange, selectedOptions]); }, [shouldParameterize, onChange, selectedOptions]);
// Show options when input text changes // Show options when input text changes
useEffect(() => { useEffect(() => {

View File

@ -10,7 +10,7 @@ import ResponsiveText from '../primitives/ResponsiveText';
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config'; import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
import EntityHover from './EntityHover'; import EntityHover from './EntityHover';
import { getPhotosCachedAction } from '@/photo/actions'; import { getPhotosCachedAction } from '@/photo/actions';
import { PhotoQueryOptions } from '@/photo/db'; import { PhotoQueryOptions } from '@/db';
import { MAX_PHOTOS_TO_SHOW_PER_CATEGORY } from '@/image-response'; import { MAX_PHOTOS_TO_SHOW_PER_CATEGORY } from '@/image-response';
export interface EntityLinkExternalProps { export interface EntityLinkExternalProps {
@ -22,9 +22,10 @@ export interface EntityLinkExternalProps {
prefetch?: boolean prefetch?: boolean
suppressSpinner?: boolean suppressSpinner?: boolean
className?: string className?: string
countOnHover?: number truncate?: boolean
showHover?: boolean hoverCount?: number
hoverPhotoQueryOptions?: PhotoQueryOptions hoverType?: 'auto' | 'text' | 'image' | 'none'
hoverQueryOptions?: PhotoQueryOptions
} }
export default function EntityLink({ export default function EntityLink({
@ -39,9 +40,9 @@ export default function EntityLink({
badged, badged,
contrast = 'medium', contrast = 'medium',
path = '', // Make link optional for debugging purposes path = '', // Make link optional for debugging purposes
showHover = SHOW_CATEGORY_IMAGE_HOVERS, hoverCount = 0,
countOnHover, hoverType = 'auto',
hoverPhotoQueryOptions, hoverQueryOptions,
prefetch, prefetch,
title, title,
action, action,
@ -62,7 +63,6 @@ export default function EntityLink({
prefetch?: boolean prefetch?: boolean
title?: string title?: string
action?: ReactNode action?: ReactNode
truncate?: boolean
className?: string className?: string
classNameIcon?: string classNameIcon?: string
uppercase?: boolean uppercase?: boolean
@ -85,10 +85,21 @@ export default function EntityLink({
} }
}; };
const showHoverEntity = const canShowHover =
!isLoading && !isLoading &&
countOnHover && hoverCount > 0;
!showHover;
const showHoverImage =
canShowHover && SHOW_CATEGORY_IMAGE_HOVERS && (
hoverType === 'auto' ||
hoverType === 'image'
);
const showHoverText =
canShowHover && (
(hoverType === 'auto' && !SHOW_CATEGORY_IMAGE_HOVERS) ||
hoverType === 'text'
);
const renderLabel = const renderLabel =
<ResponsiveText shortText={labelSmall}> <ResponsiveText shortText={labelSmall}>
@ -162,14 +173,14 @@ export default function EntityLink({
className, className,
)} )}
> >
{showHover && countOnHover && hoverPhotoQueryOptions {showHoverImage
? <EntityHover ? <EntityHover
hoverKey={path} hoverKey={path}
header={renderLink(true)} header={renderLink(true)}
photosCount={countOnHover} photosCount={hoverCount}
getPhotos={() => getPhotos={() =>
getPhotosCachedAction({ getPhotosCachedAction({
...hoverPhotoQueryOptions, ...hoverQueryOptions,
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
})} })}
color={contrast === 'frosted' ? 'frosted' : undefined} color={contrast === 'frosted' ? 'frosted' : undefined}
@ -181,9 +192,9 @@ export default function EntityLink({
<span className="action"> <span className="action">
{action} {action}
</span>} </span>}
{showHoverEntity && {showHoverText &&
<span className="hidden peer-hover:inline text-dim"> <span className="hidden peer-hover:inline text-dim">
{countOnHover} {hoverCount}
</span>} </span>}
{isLoading && !suppressSpinner && {isLoading && !suppressSpinner &&
<Spinner <Spinner

View File

@ -0,0 +1,9 @@
import { IconBaseProps } from 'react-icons';
import { LuFolderClosed } from 'react-icons/lu';
export default function IconAlbum(props: IconBaseProps) {
return <LuFolderClosed {...{
...props,
size: props.size ?? 14,
}} />;
}

View File

@ -1,8 +1,9 @@
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { PhotoSetCategory } from '../../category'; import { PhotoSetCategory } from '@/category';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
import { APP_DEFAULT_SORT_BY, SortBy } from '../sort'; import { APP_DEFAULT_SORT_BY, SortBy } from '@/photo/sort';
import { Album } from '@/album';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000; export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100; export const PHOTO_DEFAULT_LIMIT = 100;
@ -32,14 +33,20 @@ export type PhotoQueryOptions = {
updatedBefore?: Date updatedBefore?: Date
excludeFromFeeds?: boolean excludeFromFeeds?: boolean
hidden?: 'exclude' | 'include' | 'only' hidden?: 'exclude' | 'include' | 'only'
} & Omit<PhotoSetCategory, 'camera' | 'lens'> & { } & Omit<PhotoSetCategory, 'camera' | 'lens' | 'album'> & {
camera?: Partial<Camera> camera?: Partial<Camera>
lens?: Partial<Lens> lens?: Partial<Lens>
album?: Album
}; };
export const areOptionsSensitive = (options: PhotoQueryOptions) => export const areOptionsSensitive = (options: PhotoQueryOptions) =>
options.hidden === 'include' || options.hidden === 'only'; options.hidden === 'include' || options.hidden === 'only';
export const getJoinsFromOptions = (options: PhotoQueryOptions) =>
options.album
? 'JOIN album_photo ap ON ap.photo_id = p.id'
: undefined;
export const getWheresFromOptions = ( export const getWheresFromOptions = (
options: PhotoQueryOptions, options: PhotoQueryOptions,
initialValuesIndex = 1, initialValuesIndex = 1,
@ -54,6 +61,7 @@ export const getWheresFromOptions = (
maximumAspectRatio, maximumAspectRatio,
recent, recent,
year, year,
album,
tag, tag,
camera, camera,
lens, lens,
@ -129,6 +137,10 @@ export const getWheresFromOptions = (
if (!lens.make) { wheres.push('lens_make IS NULL'); } if (!lens.make) { wheres.push('lens_make IS NULL'); }
wheresValues.push(parameterize(lens.model)); wheresValues.push(parameterize(lens.model));
} }
if (album) {
wheres.push(`album_id=$${valuesIndex++}`);
wheresValues.push(album.id);
}
if (tag) { if (tag) {
wheres.push(`$${valuesIndex++}=ANY(tags)`); wheres.push(`$${valuesIndex++}=ANY(tags)`);
wheresValues.push(tag); wheresValues.push(tag);

101
src/db/query.ts Normal file
View File

@ -0,0 +1,101 @@
import { migrationForError } from './migration';
import { createPhotosTable } from '@/photo/query';
import sleep from '@/utility/sleep';
import { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config';
import { createAlbumPhotoTable, createAlbumsTable } from '@/album/query';
// Safe wrapper intended for most queries with JIT migration/table creation
// Catches up to 3 migrations in older installations
export const safelyQuery = async <T>(
callback: () => Promise<T>,
queryLabel: string,
queryOptions?: object,
): Promise<T> => {
let result: T;
const start = new Date();
try {
result = await callback();
} catch (e: any) {
// Catch 1st migration
let migration = migrationForError(e);
if (migration) {
console.log(`Running Migration ${migration.label} ...`);
await migration.run();
try {
result = await callback();
} catch (e: any) {
// Catch 2nd migration
migration = migrationForError(e);
if (migration) {
console.log(`Running Migration ${migration.label} ...`);
await migration.run();
result = await callback();
} else {
try {
result = await callback();
} catch (e: any) {
// Catch 3rd migration
migration = migrationForError(e);
if (migration) {
console.log(`Running Migration ${migration.label} ...`);
await migration.run();
result = await callback();
} else {
throw e;
}
}
}
}
} else if (/relation "photos" does not exist/i.test(e.message)) {
// Create all tables if 'photos' doesn't exist
console.log('Creating all tables ...');
await createPhotosTable();
await createAlbumsTable();
await createAlbumPhotoTable();
result = await callback();
} else if (/relation "albums" does not exist/i.test(e.message)) {
// Create albums tables if they don't exist
console.log('Creating albums tables ...');
await createAlbumsTable();
await createAlbumPhotoTable();
result = await callback();
} else if (/endpoint is in transition/i.test(e.message)) {
console.log(
'SQL query error: endpoint is in transition (setting timeout)',
);
// Wait 5 seconds and try again
await sleep(5000);
try {
result = await callback();
} catch (e: any) {
console.log(
`SQL query error on retry (after 5000ms): ${e.message}`,
);
throw e;
}
} else {
// Avoid re-logging errors on initial installation
if (e.message !== 'The server does not support SSL connections') {
console.log(`SQL query error (${queryLabel}): ${e.message}`, {
error: e,
});
}
throw e;
}
}
if (ADMIN_SQL_DEBUG_ENABLED && queryLabel) {
const time =
(((new Date()).getTime() - start.getTime()) / 1000).toFixed(2);
const message = `Debug query: ${queryLabel} (${time} seconds)`;
if (queryOptions) {
console.log(message, { options: queryOptions });
} else {
console.log(message);
}
}
return result;
};

View File

@ -1,5 +1,5 @@
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config'; import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { PhotoQueryOptions } from '../photo/db'; import { PhotoQueryOptions } from '@/db';
import { import {
INFINITE_SCROLL_FULL_INITIAL, INFINITE_SCROLL_FULL_INITIAL,
INFINITE_SCROLL_GRID_INITIAL, INFINITE_SCROLL_GRID_INITIAL,

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import { descriptionForFilmPhotos } from '.'; import { descriptionForFilmPhotos } from '.';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFilm from '@/film/PhotoFilm'; import PhotoFilm from '@/film/PhotoFilm';
@ -22,7 +22,7 @@ export default function FilmHeader({
selectedPhoto?: Photo selectedPhoto?: Photo
indexNumber?: number indexNumber?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
}) { }) {
const { recipeModalProps, setRecipeModalProps } = useAppState(); const { recipeModalProps, setRecipeModalProps } = useAppState();
@ -43,7 +43,7 @@ export default function FilmHeader({
toggleRecipeOverlay={recipeProps toggleRecipeOverlay={recipeProps
? () => setRecipeModalProps?.(recipeProps) ? () => setRecipeModalProps?.(recipeProps)
: undefined} : undefined}
showHover={false} hoverType="none"
/>} />}
entityDescription={descriptionForFilmPhotos( entityDescription={descriptionForFilmPhotos(
photos, photos,

View File

@ -1,7 +1,7 @@
import { Photo } from '../photo'; import { Photo } from '../photo';
import ImageCaption from './components/ImageCaption'; import ImageCaption from '@/image-response/components/ImageCaption';
import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer'; import ImageContainer from '@/image-response/components/ImageContainer';
import PhotoFilmIcon from import PhotoFilmIcon from
'@/film/PhotoFilmIcon'; '@/film/PhotoFilmIcon';
import { NextImageSize } from '@/platforms/next-image'; import { NextImageSize } from '@/platforms/next-image';

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import { import {
pathForFilm, pathForFilm,
pathForFilmImage, pathForFilmImage,
@ -19,7 +19,7 @@ export default function FilmOGTile({
film: string film: string
photos: Photo[] photos: Photo[]
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
} & OGTilePropsCore) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (

View File

@ -1,4 +1,4 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import FilmHeader from './FilmHeader'; import FilmHeader from './FilmHeader';
import PhotoGridContainer from '@/photo/PhotoGridContainer'; import PhotoGridContainer from '@/photo/PhotoGridContainer';
@ -12,7 +12,7 @@ export default function FilmOverview({
film: string, film: string,
photos: Photo[], photos: Photo[],
count: number, count: number,
dateRange?: PhotoDateRange, dateRange?: PhotoDateRangePostgres,
animateOnFirstLoadOnly?: boolean, animateOnFirstLoadOnly?: boolean,
}) { }) {
return ( return (

View File

@ -10,6 +10,7 @@ import { labelForFilm } from '.';
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton'; import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import useCategoryCounts from '@/category/useCategoryCounts';
export default function PhotoFilm({ export default function PhotoFilm({
film, film,
@ -23,6 +24,8 @@ export default function PhotoFilm({
film: string film: string
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>> } & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) { & EntityLinkExternalProps) {
const { getFilmCount } = useCategoryCounts();
const { small, medium, large } = labelForFilm(film); const { small, medium, large } = labelForFilm(film);
return ( return (
@ -31,7 +34,7 @@ export default function PhotoFilm({
label={medium} label={medium}
labelSmall={small} labelSmall={small}
path={pathForFilm(film)} path={pathForFilm(film)}
hoverPhotoQueryOptions={{ film }} hoverQueryOptions={{ film }}
icon={<PhotoFilmIcon icon={<PhotoFilmIcon
film={film} film={film}
className={clsx( className={clsx(
@ -51,6 +54,7 @@ export default function PhotoFilm({
isShowingRecipeOverlay, isShowingRecipeOverlay,
}} />} }} />}
iconWide={isStringFujifilmSimulation(film)} iconWide={isStringFujifilmSimulation(film)}
hoverCount={props.hoverCount ?? getFilmCount(film)}
/> />
); );
} }

View File

@ -1,6 +1,6 @@
import { import {
Photo, Photo,
PhotoDateRange, PhotoDateRangePostgres,
descriptionForPhotoSet, descriptionForPhotoSet,
photoQuantityText, photoQuantityText,
} from '@/photo'; } from '@/photo';
@ -75,7 +75,7 @@ export const descriptionForFilmPhotos = (
appText: AppTextState, appText: AppTextState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRangePostgres,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
@ -91,7 +91,7 @@ export const generateMetaForFilm = (
photos: Photo[], photos: Photo[],
appText: AppTextState, appText: AppTextState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRangePostgres,
) => ({ ) => ({
url: absolutePathForFilm(film), url: absolutePathForFilm(film),
title: titleForFilm(film, photos, appText, explicitCount), title: titleForFilm(film, photos, appText, explicitCount),

View File

@ -1,4 +1,4 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import { descriptionForFocalLengthPhotos } from '.'; import { descriptionForFocalLengthPhotos } from '.';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFocalLength from './PhotoFocalLength'; import PhotoFocalLength from './PhotoFocalLength';
@ -18,7 +18,7 @@ export default async function FocalLengthHeader({
selectedPhoto?: Photo selectedPhoto?: Photo
indexNumber?: number indexNumber?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
}) { }) {
const appText = await getAppText(); const appText = await getAppText();
return ( return (
@ -27,7 +27,7 @@ export default async function FocalLengthHeader({
entity={<PhotoFocalLength entity={<PhotoFocalLength
focal={focal} focal={focal}
contrast="high" contrast="high"
showHover={false} hoverType="none"
/>} />}
entityDescription={descriptionForFocalLengthPhotos( entityDescription={descriptionForFocalLengthPhotos(
photos, photos,

View File

@ -1,7 +1,7 @@
import type { Photo } from '../photo'; import type { Photo } from '../photo';
import ImageCaption from './components/ImageCaption'; import ImageCaption from '@/image-response/components/ImageCaption';
import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer'; import ImageContainer from '@/image-response/components/ImageContainer';
import type { NextImageSize } from '@/platforms/next-image'; import type { NextImageSize } from '@/platforms/next-image';
import { formatFocalLength } from '@/focal'; import { formatFocalLength } from '@/focal';
import IconFocalLength from '@/components/icons/IconFocalLength'; import IconFocalLength from '@/components/icons/IconFocalLength';

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import { import {
pathForFocalLength, pathForFocalLength,
pathForFocalLengthImage, pathForFocalLengthImage,
@ -19,7 +19,7 @@ export default function FocalLengthOGTile({
focal: number focal: number
photos: Photo[] photos: Photo[]
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRangePostgres
} & OGTilePropsCore) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (

View File

@ -1,4 +1,4 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRangePostgres } from '@/photo';
import PhotoGridContainer from '@/photo/PhotoGridContainer'; import PhotoGridContainer from '@/photo/PhotoGridContainer';
import FocalLengthHeader from './FocalLengthHeader'; import FocalLengthHeader from './FocalLengthHeader';
@ -12,7 +12,7 @@ export default function FocalLengthOverview({
focal: number, focal: number,
photos: Photo[], photos: Photo[],
count: number, count: number,
dateRange?: PhotoDateRange, dateRange?: PhotoDateRangePostgres,
animateOnFirstLoadOnly?: boolean, animateOnFirstLoadOnly?: boolean,
}) { }) {
return ( return (

Some files were not shown because too many files have changed in this diff Show More