Albums (#315)
* 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:
parent
62e8392900
commit
1e66815a3d
53
app/admin/albums/[album]/edit/page.tsx
Normal file
53
app/admin/albums/[album]/edit/page.tsx
Normal 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
18
app/admin/albums/page.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,7 @@ import {
|
||||
getOptimizedPhotoUrlForManipulation,
|
||||
getStorageUrlsForPhoto,
|
||||
} from '@/photo/storage';
|
||||
import { getAlbumsWithMeta, getAlbumTitlesForPhoto } from '@/album/query';
|
||||
|
||||
export default async function PhotoEditPage({
|
||||
params,
|
||||
@ -27,11 +28,15 @@ export default async function PhotoEditPage({
|
||||
|
||||
const [
|
||||
photo,
|
||||
photoAlbumTitles,
|
||||
albums,
|
||||
uniqueTags,
|
||||
uniqueRecipes,
|
||||
uniqueFilms,
|
||||
] = await Promise.all([
|
||||
getPhotoNoStore(photoId, true),
|
||||
getAlbumTitlesForPhoto(photoId),
|
||||
getAlbumsWithMeta(),
|
||||
getUniqueTagsCached(),
|
||||
getUniqueRecipesCached(),
|
||||
getUniqueFilmsCached(),
|
||||
@ -60,6 +65,8 @@ export default async function PhotoEditPage({
|
||||
<PhotoEditPageClient {...{
|
||||
photo,
|
||||
photoStorageUrls,
|
||||
photoAlbumTitles,
|
||||
albums,
|
||||
uniqueTags,
|
||||
uniqueRecipes,
|
||||
uniqueFilms,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 AdminPhotosClient from '@/admin/AdminPhotosClient';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import AdminPhotosUpdateClient from '@/admin/AdminPhotosUpdateClient';
|
||||
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { getPhotosInNeedOfUpdate } from '@/photo/db/query';
|
||||
import { getPhotosInNeedOfUpdate } from '@/photo/query';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ export default async function RecipePageEdit({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminRecipeForm {...{ recipe, photos }}>
|
||||
<AdminRecipeForm {...{ recipe }}>
|
||||
<PhotoLightbox
|
||||
{...{ count, photos, recipe }}
|
||||
maxPhotosToShow={MAX_PHOTO_TO_SHOW}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import AdminRecipeTable from '@/admin/AdminRecipeTable';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import { getUniqueRecipes } from '@/photo/db/query';
|
||||
import { getUniqueRecipes } from '@/photo/query';
|
||||
|
||||
export default async function AdminRecipesPage() {
|
||||
const recipes = await getUniqueRecipes().catch(() => []);
|
||||
|
||||
@ -12,7 +12,7 @@ interface Props {
|
||||
params: Promise<{ tag: string }>
|
||||
}
|
||||
|
||||
export default async function PhotoPageEdit({
|
||||
export default async function TagPageEdit({
|
||||
params,
|
||||
}: Props) {
|
||||
const { tag: tagFromParams } = await params;
|
||||
@ -35,7 +35,7 @@ export default async function PhotoPageEdit({
|
||||
backLabel="Tags"
|
||||
breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
|
||||
>
|
||||
<AdminTagForm {...{ tag, photos }}>
|
||||
<AdminTagForm {...{ tag }}>
|
||||
<PhotoLightbox
|
||||
{...{ count, photos, tag }}
|
||||
maxPhotosToShow={MAX_PHOTO_TO_SHOW}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import AdminTagTable from '@/admin/AdminTagTable';
|
||||
import AdminTagsTable from '@/admin/AdminTagsTable';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import { getUniqueTags } from '@/photo/db/query';
|
||||
import { getUniqueTags } from '@/photo/query';
|
||||
|
||||
export default async function AdminTagsPage() {
|
||||
const tags = await getUniqueTags().catch(() => []);
|
||||
@ -10,7 +10,7 @@ export default async function AdminTagsPage() {
|
||||
contentMain={
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<AdminTagTable {...{ tags }} />
|
||||
<AdminTagsTable {...{ tags }} />
|
||||
</div>
|
||||
</div>}
|
||||
/>
|
||||
|
||||
@ -13,7 +13,8 @@ import {
|
||||
BLUR_ENABLED,
|
||||
} from '@/app/config';
|
||||
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;
|
||||
|
||||
@ -48,11 +49,13 @@ export default async function UploadPage({ params, searchParams }: Params) {
|
||||
}
|
||||
|
||||
const [
|
||||
albums,
|
||||
uniqueTags,
|
||||
uniqueRecipes,
|
||||
uniqueFilms,
|
||||
recipeTitle,
|
||||
] = await Promise.all([
|
||||
getAlbumsWithMeta(),
|
||||
getUniqueTagsCached(),
|
||||
getUniqueRecipesCached(),
|
||||
getUniqueFilmsCached(),
|
||||
@ -83,6 +86,7 @@ export default async function UploadPage({ params, searchParams }: Params) {
|
||||
? <UploadPageClient {...{
|
||||
blobId,
|
||||
formDataFromExif,
|
||||
albums,
|
||||
uniqueTags,
|
||||
uniqueRecipes,
|
||||
uniqueFilms,
|
||||
|
||||
97
app/album/[album]/[photoId]/page.tsx
Normal file
97
app/album/[album]/[photoId]/page.tsx
Normal 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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
52
app/album/[album]/image/route.tsx
Normal file
52
app/album/[album]/image/route.tsx
Normal 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 },
|
||||
);
|
||||
}
|
||||
99
app/album/[album]/page.tsx
Normal file
99
app/album/[album]/page.tsx
Normal 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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
@ -3,12 +3,11 @@ import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import FilmImageResponse from
|
||||
'@/image-response/FilmImageResponse';
|
||||
import FilmImageResponse from '@/film/FilmImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueFilms } from '@/photo/db/query';
|
||||
import { getUniqueFilms } from '@/photo/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { getUniqueFilms } from '@/photo/db/query';
|
||||
import { getUniqueFilms } from '@/photo/query';
|
||||
import { generateMetaForFilm } from '@/film';
|
||||
import FilmOverview from '@/film/FilmOverview';
|
||||
import { getPhotosFilmDataCached } from '@/film/data';
|
||||
|
||||
@ -6,10 +6,9 @@ import {
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import FocalLengthImageResponse from
|
||||
'@/image-response/FocalLengthImageResponse';
|
||||
import FocalLengthImageResponse from '@/focal/FocalLengthImageResponse';
|
||||
import { formatFocalLength, getFocalLengthFromString } from '@/focal';
|
||||
import { getUniqueFocalLengths } from '@/photo/db/query';
|
||||
import { getUniqueFocalLengths } from '@/photo/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -2,7 +2,7 @@ import { generateMetaForFocalLength, getFocalLengthFromString } from '@/focal';
|
||||
import FocalLengthOverview from '@/focal/FocalLengthOverview';
|
||||
import { getPhotosFocalLengthDataCached } from '@/focal/data';
|
||||
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 type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@ -2,12 +2,12 @@ import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { getPhotos } from '@/photo/query';
|
||||
import PhotoFullPage from '@/photo/PhotoFullPage';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { SortProps } from '@/photo/sort';
|
||||
import { getSortOptionsFromParams } from '@/photo/sort/path';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
@ -2,7 +2,7 @@ import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { getPhotos } from '@/photo/query';
|
||||
import PhotoFullPage from '@/photo/PhotoFullPage';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { getPhotos } from '@/photo/query';
|
||||
import { cache } from 'react';
|
||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
@ -9,7 +9,7 @@ import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { SortProps } from '@/photo/sort';
|
||||
import { getSortOptionsFromParams } from '@/photo/sort/path';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { getPhotos } from '@/photo/query';
|
||||
import { cache } from 'react';
|
||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_OG,
|
||||
} from '@/image-response';
|
||||
import HomeImageResponse from '@/image-response/HomeImageResponse';
|
||||
import HomeImageResponse from '@/app/HomeImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { APP_OG_IMAGE_QUERY_OPTIONS } from '@/feed';
|
||||
|
||||
@ -6,13 +6,13 @@ import {
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueLenses } from '@/photo/db/query';
|
||||
import { getUniqueLenses } from '@/photo/query';
|
||||
import {
|
||||
getLensFromParams,
|
||||
LensProps,
|
||||
safelyGenerateLensStaticParams,
|
||||
} from '@/lens';
|
||||
import LensImageResponse from '@/image-response/LensImageResponse';
|
||||
import LensImageResponse from '@/lens/LensImageResponse';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Metadata } from 'next/types';
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { cache } from 'react';
|
||||
import { getUniqueLenses } from '@/photo/db/query';
|
||||
import { getUniqueLenses } from '@/photo/query';
|
||||
import { generateMetaForLens } from '@/lens/meta';
|
||||
import { getPhotosLensDataCached } from '@/lens/data';
|
||||
import LensOverview from '@/lens/LensOverview';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { getPhotoCached } from '@/photo/cache';
|
||||
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
||||
import PhotoImageResponse from '@/image-response/PhotoImageResponse';
|
||||
import PhotoImageResponse from '@/photo/PhotoImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { staticallyGeneratePhotosIfConfigured } from '@/app/static';
|
||||
|
||||
@ -2,7 +2,7 @@ import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
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 { NULL_CATEGORY_DATA } from '@/category/data';
|
||||
import PhotoFullPage from '@/photo/PhotoFullPage';
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import RecentsImageResponse from
|
||||
'@/image-response/RecentsImageResponse';
|
||||
'@/recents/RecentsImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
@ -6,8 +6,8 @@ import {
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueRecipes } from '@/photo/db/query';
|
||||
import RecipeImageResponse from '@/image-response/RecipeImageResponse';
|
||||
import { getUniqueRecipes } from '@/photo/query';
|
||||
import RecipeImageResponse from '@/recipe/RecipeImageResponse';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@ -4,11 +4,11 @@ import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import CameraImageResponse from '@/image-response/CameraImageResponse';
|
||||
import CameraImageResponse from '@/camera/CameraImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueCameras } from '@/photo/db/query';
|
||||
import { getUniqueCameras } from '@/photo/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -5,7 +5,7 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { getPhotosCameraDataCached } from '@/camera/data';
|
||||
import CameraOverview from '@/camera/CameraOverview';
|
||||
import { cache } from 'react';
|
||||
import { getUniqueCameras } from '@/photo/db/query';
|
||||
import { getUniqueCameras } from '@/photo/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import {
|
||||
ABSOLUTE_PATH_FULL,
|
||||
ABSOLUTE_PATH_GRID,
|
||||
absolutePathForAlbum,
|
||||
absolutePathForCamera,
|
||||
absolutePathForFilm,
|
||||
absolutePathForFocalLength,
|
||||
@ -15,7 +16,7 @@ import {
|
||||
} from '@/app/path';
|
||||
import { isTagFavs } from '@/tag';
|
||||
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
||||
import { getPhotoIdsAndUpdatedAt } from '@/photo/db/query';
|
||||
import { getPhotoIdsAndUpdatedAt } from '@/photo/query';
|
||||
|
||||
// Cache for 24 hours
|
||||
export const revalidate = 86_400;
|
||||
@ -33,6 +34,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
years,
|
||||
cameras,
|
||||
lenses,
|
||||
albums,
|
||||
tags,
|
||||
recipes,
|
||||
films,
|
||||
@ -45,6 +47,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
years: [],
|
||||
cameras: [],
|
||||
lenses: [],
|
||||
albums: [],
|
||||
tags: [],
|
||||
recipes: [],
|
||||
films: [],
|
||||
@ -58,6 +61,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
...years.map(({ lastModified }) => lastModified),
|
||||
...cameras.map(({ lastModified }) => lastModified),
|
||||
...lenses.map(({ lastModified }) => lastModified),
|
||||
...albums.map(({ lastModified }) => lastModified),
|
||||
...tags.map(({ lastModified }) => lastModified),
|
||||
...recipes.map(({ lastModified }) => lastModified),
|
||||
...films.map(({ lastModified }) => lastModified),
|
||||
@ -104,6 +108,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: PRIORITY_CATEGORY,
|
||||
lastModified,
|
||||
})),
|
||||
// Albums
|
||||
...albums.map(({ album, lastModified }) => ({
|
||||
url: absolutePathForAlbum(album),
|
||||
priority: PRIORITY_CATEGORY,
|
||||
lastModified,
|
||||
})),
|
||||
// Tags
|
||||
...tags.map(({ tag, lastModified }) => ({
|
||||
url: absolutePathForTag(tag),
|
||||
|
||||
@ -3,11 +3,11 @@ import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import TagImageResponse from '@/image-response/TagImageResponse';
|
||||
import TagImageResponse from '@/tag/TagImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueTags } from '@/photo/db/query';
|
||||
import { getUniqueTags } from '@/photo/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { generateMetaForTag } from '@/tag';
|
||||
import TagOverview from '@/tag/TagOverview';
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
|
||||
} from '@/image-response';
|
||||
import TemplateImageResponse from
|
||||
'@/image-response/TemplateImageResponse';
|
||||
'@/app/TemplateImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response';
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
MAX_PHOTOS_TO_SHOW_TEMPLATE,
|
||||
} from '@/image-response';
|
||||
import TemplateImageResponse from
|
||||
'@/image-response/TemplateImageResponse';
|
||||
'@/app/TemplateImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response';
|
||||
|
||||
@ -3,12 +3,11 @@ import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import YearImageResponse from
|
||||
'@/image-response/YearImageResponse';
|
||||
import YearImageResponse from '@/year/YearImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueYears } from '@/photo/db/query';
|
||||
import { getUniqueYears } from '@/photo/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { getUniqueYears } from '@/photo/db/query';
|
||||
import { generateMetaForYear } from '@/years/meta';
|
||||
import YearOverview from '@/years/YearOverview';
|
||||
import { getPhotosYearDataCached } from '@/years/data';
|
||||
import { getUniqueYears } from '@/photo/query';
|
||||
import { generateMetaForYear } from '@/year/meta';
|
||||
import YearOverview from '@/year/YearOverview';
|
||||
import { getPhotosYearDataCached } from '@/year/data';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { PATH_ROOT } from '@/app/path';
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
|
||||
"analyze": "ANALYZE=true next build"
|
||||
},
|
||||
"packageManager": "pnpm@10.16.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.28",
|
||||
"@ai-sdk/rsc": "^1.0.41",
|
||||
|
||||
1122
pnpm-lock.yaml
generated
1122
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
21
src/admin/AdminAlbumBadge.tsx
Normal file
21
src/admin/AdminAlbumBadge.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
src/admin/AdminAlbumForm.tsx
Normal file
76
src/admin/AdminAlbumForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/admin/AdminAlbumsTable.tsx
Normal file
46
src/admin/AdminAlbumsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
PATH_ADMIN_ALBUMS,
|
||||
PATH_ADMIN_CONFIGURATION,
|
||||
PATH_ADMIN_INSIGHTS,
|
||||
PATH_ADMIN_PHOTOS,
|
||||
@ -32,6 +33,7 @@ import SwitcherItemMenu from '@/components/switcher/SwitcherItemMenu';
|
||||
import { MoreMenuSection } from '@/components/more/MoreMenu';
|
||||
import { FiXSquare } from 'react-icons/fi';
|
||||
import { useSelectPhotosState } from './select/SelectPhotosState';
|
||||
import IconAlbum from '@/components/icons/IconAlbum';
|
||||
|
||||
export default function AdminAppMenu({
|
||||
isOpen,
|
||||
@ -44,6 +46,7 @@ export default function AdminAppMenu({
|
||||
photosCountTotal = 0,
|
||||
photosCountNeedSync = 0,
|
||||
uploadsCount = 0,
|
||||
albumsCount = 0,
|
||||
tagsCount = 0,
|
||||
recipesCount = 0,
|
||||
isLoadingAdminData,
|
||||
@ -83,8 +86,8 @@ export default function AdminAppMenu({
|
||||
label: appText.admin.uploadPlural,
|
||||
annotation: `${uploadsCount}`,
|
||||
icon: <IconFolder
|
||||
size={16}
|
||||
className="translate-x-[1px] translate-y-[0.5px]"
|
||||
size={15}
|
||||
className="translate-x-[0.5px] translate-y-[0.5px]"
|
||||
/>,
|
||||
href: PATH_ADMIN_UPLOADS,
|
||||
});
|
||||
@ -122,6 +125,17 @@ export default function AdminAppMenu({
|
||||
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) {
|
||||
items.push({
|
||||
label: appText.admin.manageTags,
|
||||
@ -186,6 +200,7 @@ export default function AdminAppMenu({
|
||||
photosCountTotal,
|
||||
recipesCount,
|
||||
showAppInsightsLink,
|
||||
albumsCount,
|
||||
tagsCount,
|
||||
uploadsCount,
|
||||
]);
|
||||
|
||||
42
src/admin/AdminBadge.tsx
Normal file
42
src/admin/AdminBadge.tsx
Normal 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">
|
||||
|
||||
{photoLabelForCount(count, appText)}
|
||||
</span>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return (
|
||||
hideBadge
|
||||
? renderBadgeContent()
|
||||
: <Badge className="py-[3px]!">{renderBadgeContent()}</Badge>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
getUniqueTagsCached,
|
||||
} from '@/photo/cache';
|
||||
import {
|
||||
PATH_ADMIN_ALBUMS,
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_ADMIN_RECIPES,
|
||||
PATH_ADMIN_TAGS,
|
||||
@ -13,11 +14,13 @@ import {
|
||||
} from '@/app/path';
|
||||
import AdminNavClient from './AdminNavClient';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
import { getAlbumsWithMeta } from '@/album/query';
|
||||
|
||||
export default async function AdminNav() {
|
||||
const [
|
||||
countPhotos,
|
||||
countUploads,
|
||||
countAlbums,
|
||||
countTags,
|
||||
countRecipes,
|
||||
mostRecentPhotoUpdateTime,
|
||||
@ -31,6 +34,8 @@ export default async function AdminNav() {
|
||||
console.error(`Error getting blob upload urls: ${e}`);
|
||||
return 0;
|
||||
}),
|
||||
getAlbumsWithMeta().then(albums => albums.length)
|
||||
.catch(() => 0),
|
||||
getUniqueTagsCached().then(tags => tags.length)
|
||||
.catch(() => 0),
|
||||
getUniqueRecipesCached().then(recipes => recipes.length)
|
||||
@ -56,6 +61,13 @@ export default async function AdminNav() {
|
||||
count: countUploads,
|
||||
}); }
|
||||
|
||||
// Albums
|
||||
if (countAlbums > 0) { items.push({
|
||||
label: appText.category.albumPlural,
|
||||
href: PATH_ADMIN_ALBUMS,
|
||||
count: countAlbums,
|
||||
}); }
|
||||
|
||||
// Tags
|
||||
if (countTags > 0) { items.push({
|
||||
label: appText.category.tagPlural,
|
||||
|
||||
@ -19,7 +19,7 @@ export default async function AdminRecipeBadge({
|
||||
<div className={clsx(
|
||||
'inline-flex items-center gap-2',
|
||||
)}>
|
||||
<PhotoRecipe {...{ recipe }} />
|
||||
<PhotoRecipe {...{ recipe }} hoverType="image" />
|
||||
<div className="text-dim uppercase">
|
||||
<span>{count}</span>
|
||||
<span className="hidden xs:inline-block">
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { photoLabelForCount } from '@/photo';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import PhotoFavs from '@/tag/PhotoFavs';
|
||||
import { isTagFavs } from '@/tag';
|
||||
import Badge from '@/components/Badge';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
import AdminBadge from './AdminBadge';
|
||||
|
||||
export default async function AdminTagBadge({
|
||||
tag,
|
||||
@ -15,30 +12,14 @@ export default async function AdminTagBadge({
|
||||
count: number,
|
||||
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">
|
||||
|
||||
{photoLabelForCount(count, appText)}
|
||||
</span>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return (
|
||||
hideBadge
|
||||
? renderBadgeContent()
|
||||
: <Badge className="py-[3px]!">{renderBadgeContent()}</Badge>
|
||||
<AdminBadge
|
||||
className={isTagFavs(tag) ? 'translate-y-[-0.5px]' : undefined}
|
||||
entity={isTagFavs(tag)
|
||||
? <PhotoFavs hoverType="image" />
|
||||
: <PhotoTag {...{ tag }} hoverType="image" />}
|
||||
count={count}
|
||||
hideBadge={hideBadge}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -11,7 +11,7 @@ import { clsx } from 'clsx/lite';
|
||||
import AdminTagBadge from './AdminTagBadge';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
export default async function AdminTagTable({
|
||||
export default async function AdminTagsTable({
|
||||
tags,
|
||||
}: {
|
||||
tags: Tags
|
||||
@ -12,11 +12,12 @@ import {
|
||||
getUniqueTags,
|
||||
getUniqueRecipes,
|
||||
getPhotosInNeedOfUpdateCount,
|
||||
} from '@/photo/db/query';
|
||||
} from '@/photo/query';
|
||||
import {
|
||||
getGitHubMetaForCurrentApp,
|
||||
indicatorStatusForSignificantInsights,
|
||||
} from './insights';
|
||||
import { getAlbumsWithMeta } from '@/album/query';
|
||||
|
||||
export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>;
|
||||
|
||||
@ -28,6 +29,7 @@ export const getAdminDataAction = async () =>
|
||||
photosCountNeedSync,
|
||||
codeMeta,
|
||||
uploadsCount,
|
||||
albumsCount,
|
||||
tagsCount,
|
||||
recipesCount,
|
||||
] = await Promise.all([
|
||||
@ -45,6 +47,9 @@ export const getAdminDataAction = async () =>
|
||||
console.error(`Error getting blob upload urls: ${e}`);
|
||||
return 0;
|
||||
}),
|
||||
getAlbumsWithMeta()
|
||||
.then(albums => albums.length)
|
||||
.catch(() => 0),
|
||||
getUniqueTags()
|
||||
.then(tags => tags.length)
|
||||
.catch(() => 0),
|
||||
@ -71,6 +76,7 @@ export const getAdminDataAction = async () =>
|
||||
photosCountNeedSync,
|
||||
photosCountTotal,
|
||||
uploadsCount,
|
||||
albumsCount,
|
||||
tagsCount,
|
||||
recipesCount,
|
||||
insightsIndicatorStatus,
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
getUniqueRecipes,
|
||||
getUniqueTags,
|
||||
getPhotosInNeedOfUpdateCount,
|
||||
} from '@/photo/db/query';
|
||||
} from '@/photo/query';
|
||||
import AdminAppInsightsClient from './AdminAppInsightsClient';
|
||||
import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
|
||||
import { USED_DEPRECATED_ENV_VARS } from '@/app/config';
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import ScoreCard from '@/components/ScoreCard';
|
||||
import ScoreCardRow from '@/components/ScoreCardRow';
|
||||
import { dateRangeForPhotos } from '@/photo';
|
||||
import { formattedDateRangeForPhotos } from '@/photo';
|
||||
import { FaArrowRight, FaCircleInfo, FaRegCalendar } from 'react-icons/fa6';
|
||||
import { MdAspectRatio } from 'react-icons/md';
|
||||
import { PiWarningBold } from 'react-icons/pi';
|
||||
@ -125,7 +125,8 @@ export default function AdminAppInsightsClient({
|
||||
noStaticOptimization,
|
||||
} = insights;
|
||||
|
||||
const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange);
|
||||
const { descriptionWithSpaces } =
|
||||
formattedDateRangeForPhotos(undefined, dateRange);
|
||||
|
||||
const branchLink = <a
|
||||
className="truncate"
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
AI_CONTENT_GENERATION_ENABLED,
|
||||
HAS_DEPRECATED_ENV_VARS,
|
||||
} from '@/app/config';
|
||||
import { PhotoDateRange } from '@/photo';
|
||||
import { PhotoDateRangePostgres } from '@/photo';
|
||||
import { getGitHubMeta } from '@/platforms/github';
|
||||
|
||||
const BASIC_PHOTO_INSTALLATION_COUNT = 32;
|
||||
@ -64,7 +64,7 @@ export interface PhotoStats {
|
||||
recipesCount: number
|
||||
filmsCount: number
|
||||
focalLengthsCount: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
}
|
||||
|
||||
export const getGitHubMetaForCurrentApp = () =>
|
||||
|
||||
91
src/album/AlbumHeader.tsx
Normal file
91
src/album/AlbumHeader.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
src/album/AlbumImageResponse.tsx
Normal file
46
src/album/AlbumImageResponse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/album/AlbumOverview.tsx
Normal file
38
src/album/AlbumOverview.tsx
Normal 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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
26
src/album/AlbumShareModal.tsx
Normal file
26
src/album/AlbumShareModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
src/album/FieldsetAlbum.tsx
Normal file
19
src/album/FieldsetAlbum.tsx
Normal 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
27
src/album/PhotoAlbum.tsx
Normal 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
23
src/album/actions.ts
Normal 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
16
src/album/data.ts
Normal 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
52
src/album/form.ts
Normal 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
85
src/album/index.ts
Normal 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
124
src/album/query.ts
Normal 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
30
src/album/server.ts
Normal 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)));
|
||||
};
|
||||
@ -1,8 +1,8 @@
|
||||
import { NAV_TITLE } from '@/app/config';
|
||||
import { Photo } from '../photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageCaption from '@/image-response/components/ImageCaption';
|
||||
import ImageContainer from '@/image-response/components/ImageContainer';
|
||||
import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
|
||||
export default function HomeImageResponse({
|
||||
@ -1,7 +1,7 @@
|
||||
import { Photo } from '../photo';
|
||||
import IconFull from '@/components/icons/IconFull';
|
||||
import IconGrid from '@/components/icons/IconGrid';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImagePhotoGrid from '../image-response/components/ImagePhotoGrid';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
|
||||
export default function TemplateImageResponse({
|
||||
@ -274,6 +274,8 @@ export const SHOW_CAMERAS =
|
||||
CATEGORY_VISIBILITY.includes('cameras');
|
||||
export const SHOW_LENSES =
|
||||
CATEGORY_VISIBILITY.includes('lenses');
|
||||
export const SHOW_ALBUMS =
|
||||
CATEGORY_VISIBILITY.includes('albums');
|
||||
export const SHOW_TAGS =
|
||||
CATEGORY_VISIBILITY.includes('tags');
|
||||
export const SHOW_RECIPES =
|
||||
|
||||
@ -5,6 +5,7 @@ import { Camera } from '@/camera';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
import { Lens } from '@/lens';
|
||||
import { Album, AlbumOrAlbumSlug } from '@/album';
|
||||
|
||||
// Core
|
||||
export const PATH_ROOT = '/';
|
||||
@ -43,6 +44,7 @@ export const PATH_FEED_JSON = '/feed.json';
|
||||
export const PREFIX_PHOTO = '/p';
|
||||
export const PREFIX_CAMERA = '/shot-on';
|
||||
export const PREFIX_LENS = '/lens';
|
||||
export const PREFIX_ALBUM = '/album';
|
||||
export const PREFIX_TAG = '/tag';
|
||||
export const PREFIX_RECIPE = '/recipe';
|
||||
export const PREFIX_FILM = '/film';
|
||||
@ -54,6 +56,7 @@ export const PREFIX_RECENTS = '/recents';
|
||||
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
||||
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[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_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`;
|
||||
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_UPDATES = `${PATH_ADMIN_PHOTOS}/updates`;
|
||||
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_RECIPES = `${PATH_ADMIN}/recipes`;
|
||||
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
||||
@ -95,6 +99,7 @@ export const PATHS_ADMIN = [
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_ADMIN_PHOTOS_UPDATES,
|
||||
PATH_ADMIN_UPLOADS,
|
||||
PATH_ADMIN_ALBUMS,
|
||||
PATH_ADMIN_TAGS,
|
||||
PATH_ADMIN_RECIPES,
|
||||
PATH_ADMIN_INSIGHTS,
|
||||
@ -111,6 +116,7 @@ export const PATHS_TO_CACHE = [
|
||||
PATH_PHOTO_DYNAMIC,
|
||||
PATH_CAMERA_DYNAMIC,
|
||||
PATH_LENS_DYNAMIC,
|
||||
PATH_ALBUM_DYNAMIC,
|
||||
PATH_TAG_DYNAMIC,
|
||||
PATH_FILM_DYNAMIC,
|
||||
PATH_FOCAL_LENGTH_DYNAMIC,
|
||||
@ -131,6 +137,9 @@ export const pathForAdminUploadUrl = (url: string, title?: string) =>
|
||||
export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) =>
|
||||
`${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`;
|
||||
|
||||
export const pathForAdminAlbumEdit = (album: Album) =>
|
||||
`${PATH_ADMIN_ALBUMS}/${album.slug}/${EDIT}`;
|
||||
|
||||
export const pathForAdminTagEdit = (tag: string) =>
|
||||
`${PATH_ADMIN_TAGS}/${tag}/${EDIT}`;
|
||||
|
||||
@ -148,6 +157,7 @@ export const pathForPhoto = ({
|
||||
year,
|
||||
camera,
|
||||
lens,
|
||||
album,
|
||||
tag,
|
||||
film,
|
||||
focal,
|
||||
@ -165,6 +175,8 @@ export const pathForPhoto = ({
|
||||
prefix = pathForCamera(camera);
|
||||
} else if (lens) {
|
||||
prefix = pathForLens(lens);
|
||||
} else if (album) {
|
||||
prefix = pathForAlbum(album);
|
||||
} else if (tag) {
|
||||
prefix = pathForTag(tag);
|
||||
} else if (recipe) {
|
||||
@ -178,6 +190,9 @@ export const pathForPhoto = ({
|
||||
return `${prefix}/${getPhotoId(photo)}`;
|
||||
};
|
||||
|
||||
export const pathForYear = (year: string) =>
|
||||
`${PREFIX_YEAR}/${year}`;
|
||||
|
||||
export const pathForCamera = ({ make, model }: Camera) =>
|
||||
`${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`;
|
||||
|
||||
@ -186,6 +201,9 @@ export const pathForLens = ({ make, model }: Lens) =>
|
||||
? `${PREFIX_LENS}/${parameterize(make)}/${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) =>
|
||||
`${PREFIX_TAG}/${tag}`;
|
||||
|
||||
@ -198,9 +216,6 @@ export const pathForFilm = (film: string) =>
|
||||
export const pathForFocalLength = (focal: number) =>
|
||||
`${PREFIX_FOCAL_LENGTH}/${focal}mm`;
|
||||
|
||||
export const pathForYear = (year: string) =>
|
||||
`${PREFIX_YEAR}/${year}`;
|
||||
|
||||
// Image paths
|
||||
const pathForImage = (path: string) =>
|
||||
`${path}/${IMAGE}`;
|
||||
@ -214,6 +229,9 @@ export const pathForCameraImage = (camera: Camera) =>
|
||||
export const pathForLensImage = (lens: Lens) =>
|
||||
pathForImage(pathForLens(lens));
|
||||
|
||||
export const pathForAlbumImage = (album: Album) =>
|
||||
pathForImage(pathForAlbum(album));
|
||||
|
||||
export const pathForTagImage = (tag: string) =>
|
||||
pathForImage(pathForTag(tag));
|
||||
|
||||
@ -259,6 +277,9 @@ export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
|
||||
|
||||
export const absolutePathForLens= (lens: Lens, share?: boolean) =>
|
||||
`${getBaseUrl(share)}${pathForLens(lens)}`;
|
||||
|
||||
export const absolutePathForAlbum = (album: Album, share?: boolean) =>
|
||||
`${getBaseUrl(share)}${pathForAlbum(album)}`;
|
||||
|
||||
export const absolutePathForTag = (tag: string, share?: boolean) =>
|
||||
`${getBaseUrl(share)}${pathForTag(tag)}`;
|
||||
@ -279,31 +300,34 @@ export const absolutePathForRecents = (share?: boolean) =>
|
||||
`${getBaseUrl(share)}${PREFIX_RECENTS}`;
|
||||
|
||||
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
||||
`${getBaseUrl()}${pathForPhotoImage(photo)}`;
|
||||
`${absolutePathForPhoto({ photo })}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForCameraImage= (camera: Camera) =>
|
||||
`${getBaseUrl()}${pathForCameraImage(camera)}`;
|
||||
`${absolutePathForCamera(camera)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForLensImage= (lens: Lens) =>
|
||||
`${getBaseUrl()}${pathForLensImage(lens)}`;
|
||||
`${absolutePathForLens(lens)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForAlbumImage = (album: Album) =>
|
||||
`${absolutePathForAlbum(album)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForTagImage = (tag: string) =>
|
||||
`${getBaseUrl()}${pathForTagImage(tag)}`;
|
||||
`${absolutePathForTag(tag)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForRecipeImage = (recipe: string) =>
|
||||
`${getBaseUrl()}${pathForRecipeImage(recipe)}`;
|
||||
`${absolutePathForRecipe(recipe)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForFilmImage = (film: string) =>
|
||||
`${getBaseUrl()}${pathForFilmImage(film)}`;
|
||||
`${absolutePathForFilm(film)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForFocalLengthImage = (focal: number) =>
|
||||
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`;
|
||||
`${absolutePathForFocalLength(focal)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForYearImage = (year: string) =>
|
||||
`${absolutePathForYear(year)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForYearImage = (year: string, share?: boolean) =>
|
||||
`${getBaseUrl(share)}${pathForYearImage(year)}`;
|
||||
|
||||
export const absolutePathForRecentsImage = (share?: boolean) =>
|
||||
`${getBaseUrl(share)}${pathForRecentsImage()}`;
|
||||
export const absolutePathForRecentsImage = () =>
|
||||
`${absolutePathForRecents()}/${IMAGE}`;
|
||||
|
||||
// p/[photoId]
|
||||
export const isPathPhoto = (pathname = '') =>
|
||||
@ -341,6 +365,14 @@ export const isPathLens = (pathname = '') =>
|
||||
export const isPathLensPhoto = (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]
|
||||
export const isPathTag = (pathname = '') =>
|
||||
new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);
|
||||
@ -417,9 +449,12 @@ export const isPathProtected = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
|
||||
checkPathPrefix(pathname, PATH_OG);
|
||||
|
||||
export const getPathComponents = (pathname = ''): {
|
||||
export const getPathComponents = (
|
||||
pathname = '',
|
||||
): (Omit<PhotoSetCategory, 'album'> & {
|
||||
album?: string
|
||||
photoId?: string
|
||||
} & PhotoSetCategory => {
|
||||
}) => {
|
||||
const photoIdFromPhoto = pathname.match(
|
||||
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
|
||||
const photoIdFromCamera = pathname.match(
|
||||
@ -438,6 +473,8 @@ export const getPathComponents = (pathname = ''): {
|
||||
new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromRecents = pathname.match(
|
||||
new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[1];
|
||||
const album = pathname.match(
|
||||
new RegExp(`^${PREFIX_ALBUM}/([^/]+)`))?.[1];
|
||||
const tag = pathname.match(
|
||||
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
|
||||
const film = pathname.match(
|
||||
@ -464,6 +501,7 @@ export const getPathComponents = (pathname = ''): {
|
||||
photoIdFromYear ||
|
||||
photoIdFromRecents
|
||||
),
|
||||
album,
|
||||
tag,
|
||||
camera,
|
||||
film,
|
||||
@ -480,6 +518,7 @@ export const getEscapePath = (pathname?: string) => {
|
||||
year,
|
||||
camera,
|
||||
lens,
|
||||
album,
|
||||
tag,
|
||||
recipe,
|
||||
film,
|
||||
@ -506,6 +545,8 @@ export const getEscapePath = (pathname?: string) => {
|
||||
return pathForCamera(camera);
|
||||
} else if (lens && isPathLensPhoto(pathname)) {
|
||||
return pathForLens(lens);
|
||||
} else if (album && isPathAlbumPhoto(pathname)) {
|
||||
return pathForAlbum(album);
|
||||
} else if (tag && isPathTagPhoto(pathname)) {
|
||||
return pathForTag(tag);
|
||||
} else if (recipe && isPathRecipePhoto(pathname)) {
|
||||
|
||||
@ -8,8 +8,8 @@ import {
|
||||
STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES,
|
||||
STATICALLY_OPTIMIZED_PHOTOS,
|
||||
} from '@/app/config';
|
||||
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
|
||||
import { getPublicPhotoIds } from '@/photo/db/query';
|
||||
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/db';
|
||||
import { getPublicPhotoIds } from '@/photo/query';
|
||||
import { depluralize, pluralize } from '@/utility/string';
|
||||
|
||||
type StaticOutput = 'page' | 'image';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import { Camera, cameraFromPhoto } from '.';
|
||||
import PhotoCamera from './PhotoCamera';
|
||||
@ -19,7 +19,7 @@ export default async function CameraHeader({
|
||||
selectedPhoto?: Photo
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
}) {
|
||||
const appText = await getAppText();
|
||||
const camera = cameraFromPhoto(photos[0], cameraProp);
|
||||
@ -30,7 +30,7 @@ export default async function CameraHeader({
|
||||
entity={<PhotoCamera
|
||||
{...{ camera }}
|
||||
contrast="high"
|
||||
showHover={false}
|
||||
hoverType="none"
|
||||
/>}
|
||||
entityDescription={
|
||||
descriptionForCameraPhotos(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Photo } from '../photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import ImageCaption from '@/image-response/components/ImageCaption';
|
||||
import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
|
||||
import ImageContainer from '@/image-response/components/ImageContainer';
|
||||
import {
|
||||
Camera,
|
||||
cameraFromPhoto,
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import { pathForCamera, pathForCameraImage } from '@/app/path';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { Camera } from '.';
|
||||
@ -17,7 +17,7 @@ export default function CameraOGTile({
|
||||
camera: Camera
|
||||
photos: Photo[]
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import { Camera, createCameraKey } from '.';
|
||||
import CameraHeader from './CameraHeader';
|
||||
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||
@ -13,7 +13,7 @@ export default function CameraOverview({
|
||||
camera: Camera,
|
||||
photos: Photo[],
|
||||
count: number,
|
||||
dateRange?: PhotoDateRange,
|
||||
dateRange?: PhotoDateRangePostgres,
|
||||
animateOnFirstLoadOnly?: boolean,
|
||||
}) {
|
||||
return (
|
||||
|
||||
@ -8,6 +8,7 @@ import EntityLink, {
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconCamera from '@/components/icons/IconCamera';
|
||||
import { isCameraApple } from '@/platforms/apple';
|
||||
import useCategoryCounts from '@/category/useCategoryCounts';
|
||||
|
||||
export default function PhotoCamera({
|
||||
camera,
|
||||
@ -17,6 +18,8 @@ export default function PhotoCamera({
|
||||
camera: Camera
|
||||
hideAppleIcon?: boolean
|
||||
} & EntityLinkExternalProps) {
|
||||
const { getCameraCount } = useCategoryCounts();
|
||||
|
||||
const isApple = isCameraApple(camera);
|
||||
const showAppleIcon = !hideAppleIcon && isApple;
|
||||
|
||||
@ -25,7 +28,7 @@ export default function PhotoCamera({
|
||||
{...props}
|
||||
label={formatCameraText(camera)}
|
||||
path={pathForCamera(camera)}
|
||||
hoverPhotoQueryOptions={{ camera }}
|
||||
hoverQueryOptions={{ camera }}
|
||||
icon={showAppleIcon
|
||||
? <AiFillApple
|
||||
title="Apple"
|
||||
@ -36,6 +39,7 @@ export default function PhotoCamera({
|
||||
size={15}
|
||||
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
||||
/>}
|
||||
hoverCount={props.hoverCount ?? getCameraCount(camera)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {
|
||||
Photo,
|
||||
PhotoDateRange,
|
||||
PhotoDateRangePostgres,
|
||||
descriptionForPhotoSet,
|
||||
photoQuantityText,
|
||||
} from '@/photo';
|
||||
@ -41,7 +41,7 @@ export const descriptionForCameraPhotos = (
|
||||
appText: AppTextState,
|
||||
dateBased?: boolean,
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
explicitDateRange?: PhotoDateRangePostgres,
|
||||
) =>
|
||||
descriptionForPhotoSet(
|
||||
photos,
|
||||
@ -57,7 +57,7 @@ export const generateMetaForCamera = (
|
||||
photos: Photo[],
|
||||
appText: AppTextState,
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
explicitDateRange?: PhotoDateRangePostgres,
|
||||
) => ({
|
||||
url: absolutePathForCamera(camera),
|
||||
title: titleForCamera(camera, photos, appText, explicitCount),
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
getUniqueRecipes,
|
||||
getUniqueTags,
|
||||
getUniqueYears,
|
||||
} from '@/photo/db/query';
|
||||
} from '@/photo/query';
|
||||
import {
|
||||
SHOW_FILMS,
|
||||
SHOW_FOCAL_LENGTHS,
|
||||
@ -17,11 +17,13 @@ import {
|
||||
SHOW_TAGS,
|
||||
SHOW_YEARS,
|
||||
SHOW_RECENTS,
|
||||
SHOW_ALBUMS,
|
||||
} from '@/app/config';
|
||||
import { createLensKey } from '@/lens';
|
||||
import { sortTagsByCount } from '@/tag';
|
||||
import { sortCategoriesByCount } from '@/category';
|
||||
import { sortFocalLengths } from '@/focal';
|
||||
import { getAlbumsWithMeta } from '@/album/query';
|
||||
|
||||
type CategoryData = Awaited<ReturnType<typeof getDataForCategories>>;
|
||||
|
||||
@ -34,6 +36,7 @@ export const NULL_CATEGORY_DATA: CategoryData = {
|
||||
recipes: [],
|
||||
films: [],
|
||||
focalLengths: [],
|
||||
albums: [],
|
||||
};
|
||||
|
||||
export const getDataForCategories = () => Promise.all([
|
||||
@ -80,6 +83,10 @@ export const getDataForCategories = () => Promise.all([
|
||||
.then(sortFocalLengths)
|
||||
.catch(() => [])
|
||||
: undefined,
|
||||
SHOW_ALBUMS
|
||||
? getAlbumsWithMeta()
|
||||
.catch(() => [])
|
||||
: undefined,
|
||||
]).then(([
|
||||
recents = [],
|
||||
years = [],
|
||||
@ -89,6 +96,7 @@ export const getDataForCategories = () => Promise.all([
|
||||
recipes = [],
|
||||
films = [],
|
||||
focalLengths = [],
|
||||
albums = [],
|
||||
]) => ({
|
||||
recents,
|
||||
years,
|
||||
@ -98,6 +106,7 @@ export const getDataForCategories = () => Promise.all([
|
||||
recipes,
|
||||
films,
|
||||
focalLengths,
|
||||
albums,
|
||||
}));
|
||||
|
||||
export const getCountsForCategories = async () => {
|
||||
@ -106,6 +115,7 @@ export const getCountsForCategories = async () => {
|
||||
years,
|
||||
cameras,
|
||||
lenses,
|
||||
albums,
|
||||
tags,
|
||||
recipes,
|
||||
films,
|
||||
@ -120,6 +130,10 @@ export const getCountsForCategories = async () => {
|
||||
acc[year.year] = year.count;
|
||||
return acc;
|
||||
}, {} 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) => {
|
||||
acc[camera.cameraKey] = camera.count;
|
||||
return acc;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo, PhotoDateRange } from '../photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '../photo';
|
||||
import { Camera, Cameras } from '@/camera';
|
||||
import { Films } from '@/film';
|
||||
import { Lens, Lenses } from '@/lens';
|
||||
@ -6,14 +6,16 @@ import { Tags } from '@/tag';
|
||||
import { FocalLengths } from '@/focal';
|
||||
import { Recipes } from '@/recipe';
|
||||
import { Recents } from '@/recents';
|
||||
import { Years } from '@/years';
|
||||
import { Years } from '@/year';
|
||||
import { parseCommaSeparatedKeyString } from '@/utility/key';
|
||||
import { Album, Albums } from '@/album';
|
||||
|
||||
export const CATEGORY_KEYS = [
|
||||
'recents',
|
||||
'years',
|
||||
'cameras',
|
||||
'lenses',
|
||||
'albums',
|
||||
'tags',
|
||||
'recipes',
|
||||
'films',
|
||||
@ -26,6 +28,7 @@ export type CategoryKeys = CategoryKey[];
|
||||
|
||||
export const DEFAULT_CATEGORY_KEYS: CategoryKeys = [
|
||||
'recents',
|
||||
'albums',
|
||||
'tags',
|
||||
'cameras',
|
||||
'lenses',
|
||||
@ -55,6 +58,7 @@ export interface PhotoSetCategory {
|
||||
year?: string
|
||||
camera?: Camera
|
||||
lens?: Lens
|
||||
album?: Album
|
||||
tag?: string
|
||||
recipe?: string
|
||||
film?: string
|
||||
@ -62,20 +66,21 @@ export interface PhotoSetCategory {
|
||||
}
|
||||
|
||||
export interface PhotoSetCategories {
|
||||
recents: Recents
|
||||
years: Years
|
||||
cameras: Cameras
|
||||
lenses: Lenses
|
||||
albums: Albums
|
||||
tags: Tags
|
||||
recipes: Recipes
|
||||
films: Films
|
||||
focalLengths: FocalLengths
|
||||
years: Years
|
||||
recents: Recents
|
||||
}
|
||||
|
||||
export interface PhotoSetAttributes {
|
||||
photos: Photo[]
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
}
|
||||
|
||||
export const sortCategoryByCount = (
|
||||
|
||||
@ -2,10 +2,18 @@ import { createCameraKey, Camera } from '@/camera';
|
||||
import { createLensKey, Lens } from '@/lens';
|
||||
import { useCallback } from 'react';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { Album } from '@/album';
|
||||
|
||||
export default function useCategoryCounts() {
|
||||
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 cameraCounts = categoriesWithCounts?.cameras ?? {};
|
||||
return cameraCounts[createCameraKey(camera)];
|
||||
@ -16,6 +24,11 @@ export default function useCategoryCounts() {
|
||||
return lensCounts[createLensKey(lens)];
|
||||
}, [categoriesWithCounts]);
|
||||
|
||||
const getAlbumCount = useCallback((album: Album) => {
|
||||
const albumCounts = categoriesWithCounts?.albums ?? {};
|
||||
return albumCounts[album.slug];
|
||||
}, [categoriesWithCounts]);
|
||||
|
||||
const getTagCount = useCallback((tag: string) => {
|
||||
const tagCounts = categoriesWithCounts?.tags ?? {};
|
||||
return tagCounts[tag];
|
||||
@ -37,8 +50,11 @@ export default function useCategoryCounts() {
|
||||
}, [categoriesWithCounts]);
|
||||
|
||||
return {
|
||||
recentsCount,
|
||||
getYearsCount,
|
||||
getCameraCount,
|
||||
getLensCount,
|
||||
getAlbumCount,
|
||||
getTagCount,
|
||||
getRecipeCount,
|
||||
getFilmCount,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -23,6 +23,7 @@ import {
|
||||
PATH_FULL_INFERRED,
|
||||
PATH_GRID_INFERRED,
|
||||
PATH_SIGN_IN,
|
||||
pathForAlbum,
|
||||
pathForCamera,
|
||||
pathForFilm,
|
||||
pathForFocalLength,
|
||||
@ -94,6 +95,7 @@ import IconCheck from '@/components/icons/IconCheck';
|
||||
import { getSortStateFromPath } from '@/photo/sort/path';
|
||||
import IconSort from '@/components/icons/IconSort';
|
||||
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
|
||||
import IconAlbum from '@/components/icons/IconAlbum';
|
||||
|
||||
const DIALOG_TITLE = 'Global Command-K Menu';
|
||||
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
|
||||
@ -140,6 +142,7 @@ export default function CommandKClient({
|
||||
years: _years,
|
||||
cameras,
|
||||
lenses,
|
||||
albums,
|
||||
tags: _tags,
|
||||
recipes,
|
||||
films,
|
||||
@ -397,6 +400,16 @@ export default function CommandKClient({
|
||||
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 {
|
||||
heading: appText.category.tagPlural,
|
||||
accessory: <IconTag
|
||||
@ -466,6 +479,7 @@ export default function CommandKClient({
|
||||
years,
|
||||
cameras,
|
||||
lenses,
|
||||
albums,
|
||||
tags,
|
||||
recipes,
|
||||
films,
|
||||
|
||||
@ -31,6 +31,7 @@ export default function FieldsetWithStatus({
|
||||
tagOptions,
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
tagOptionsShouldParameterize,
|
||||
tagOptionsDefaultIcon,
|
||||
placeholder,
|
||||
loading,
|
||||
@ -62,6 +63,7 @@ export default function FieldsetWithStatus({
|
||||
tagOptions?: AnnotatedTag[]
|
||||
tagOptionsLimit?: number
|
||||
tagOptionsLimitValidationMessage?: string
|
||||
tagOptionsShouldParameterize?: boolean
|
||||
tagOptionsDefaultIcon?: ReactNode
|
||||
placeholder?: string
|
||||
loading?: boolean
|
||||
@ -210,6 +212,7 @@ export default function FieldsetWithStatus({
|
||||
placeholder={placeholder}
|
||||
limit={tagOptionsLimit}
|
||||
limitValidationMessage={tagOptionsLimitValidationMessage}
|
||||
shouldParameterize={tagOptionsShouldParameterize}
|
||||
/>
|
||||
: type === 'textarea'
|
||||
? <textarea
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, RefObject, useRef } from 'react';
|
||||
import useMaskedScroll from './useMaskedScroll';
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ export default function TagInput({
|
||||
placeholder,
|
||||
limit,
|
||||
limitValidationMessage,
|
||||
shouldParameterize,
|
||||
}: {
|
||||
id?: string
|
||||
name: string
|
||||
@ -43,6 +44,7 @@ export default function TagInput({
|
||||
placeholder?: string
|
||||
limit?: number
|
||||
limitValidationMessage?: string
|
||||
shouldParameterize?: boolean
|
||||
}) {
|
||||
const behaveAsDropdown = limit === 1;
|
||||
|
||||
@ -59,8 +61,8 @@ export default function TagInput({
|
||||
, [options]);
|
||||
|
||||
const selectedOptions = useMemo(() =>
|
||||
convertStringToArray(value) ?? []
|
||||
, [value]);
|
||||
convertStringToArray(value, shouldParameterize) ?? []
|
||||
, [value, shouldParameterize]);
|
||||
|
||||
const hasReachedLimit = useMemo(() =>
|
||||
limit !== undefined &&
|
||||
@ -68,14 +70,30 @@ export default function TagInput({
|
||||
!behaveAsDropdown
|
||||
, [limit, behaveAsDropdown, selectedOptions]);
|
||||
|
||||
const inputTextFormatted = parameterize(inputText);
|
||||
const isInputTextUnique =
|
||||
inputTextFormatted &&
|
||||
!optionValues.includes(inputTextFormatted) &&
|
||||
!selectedOptions.includes(inputTextFormatted);
|
||||
const inputTextFormatted = shouldParameterize
|
||||
? parameterize(inputText)
|
||||
: inputText.trim();
|
||||
const isInputTextUnique = useMemo(() => {
|
||||
if (shouldParameterize) {
|
||||
// Check already-parameterized values
|
||||
return inputTextFormatted &&
|
||||
!optionValues.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
|
||||
? [{ value: limitValidationMessage ?? `Tag limit reached (${limit})` }]
|
||||
? [{ value: limitValidationMessage ?? `Limit reached (${limit})` }]
|
||||
: (isInputTextUnique
|
||||
? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
|
||||
: []
|
||||
@ -84,7 +102,9 @@ export default function TagInput({
|
||||
!selectedOptions.includes(value) &&
|
||||
(
|
||||
!inputTextFormatted ||
|
||||
value.includes(inputTextFormatted)
|
||||
(shouldParameterize
|
||||
? value.includes(inputTextFormatted)
|
||||
: (parameterize(value)).includes(parameterize(inputTextFormatted)))
|
||||
)))
|
||||
, [
|
||||
hasReachedLimit,
|
||||
@ -94,6 +114,7 @@ export default function TagInput({
|
||||
limitValidationMessage,
|
||||
options,
|
||||
selectedOptions,
|
||||
shouldParameterize,
|
||||
]);
|
||||
|
||||
const hideMenu = useCallback((shouldBlurInput?: boolean) => {
|
||||
@ -110,7 +131,9 @@ export default function TagInput({
|
||||
.map(option => option.startsWith(CREATE_LABEL)
|
||||
? option.match(new RegExp(`^${CREATE_LABEL} "(.+)"$`))?.[1] ?? option
|
||||
: option)
|
||||
.map(option => parameterize(option))
|
||||
.map(option => shouldParameterize
|
||||
? parameterize(option)
|
||||
: option)
|
||||
.filter(option => !selectedOptions.includes(option));
|
||||
|
||||
if (optionsToAdd.length > 0) {
|
||||
@ -136,14 +159,22 @@ export default function TagInput({
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [limit, behaveAsDropdown, selectedOptions, onChange, hideMenu]);
|
||||
}, [
|
||||
limit,
|
||||
behaveAsDropdown,
|
||||
selectedOptions,
|
||||
shouldParameterize,
|
||||
onChange,
|
||||
hideMenu,
|
||||
]);
|
||||
|
||||
const removeOption = useCallback((option: string) => {
|
||||
onChange?.(selectedOptions.filter(o =>
|
||||
o !== parameterize(option)).join(','));
|
||||
onChange?.(selectedOptions
|
||||
.filter(o => o !== (shouldParameterize ? parameterize(option) : option))
|
||||
.join(','));
|
||||
setSelectedOptionIndex(undefined);
|
||||
inputRef.current?.focus();
|
||||
}, [onChange, selectedOptions]);
|
||||
}, [shouldParameterize, onChange, selectedOptions]);
|
||||
|
||||
// Show options when input text changes
|
||||
useEffect(() => {
|
||||
|
||||
@ -10,7 +10,7 @@ import ResponsiveText from '../primitives/ResponsiveText';
|
||||
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
|
||||
import EntityHover from './EntityHover';
|
||||
import { getPhotosCachedAction } from '@/photo/actions';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
import { MAX_PHOTOS_TO_SHOW_PER_CATEGORY } from '@/image-response';
|
||||
|
||||
export interface EntityLinkExternalProps {
|
||||
@ -22,9 +22,10 @@ export interface EntityLinkExternalProps {
|
||||
prefetch?: boolean
|
||||
suppressSpinner?: boolean
|
||||
className?: string
|
||||
countOnHover?: number
|
||||
showHover?: boolean
|
||||
hoverPhotoQueryOptions?: PhotoQueryOptions
|
||||
truncate?: boolean
|
||||
hoverCount?: number
|
||||
hoverType?: 'auto' | 'text' | 'image' | 'none'
|
||||
hoverQueryOptions?: PhotoQueryOptions
|
||||
}
|
||||
|
||||
export default function EntityLink({
|
||||
@ -39,9 +40,9 @@ export default function EntityLink({
|
||||
badged,
|
||||
contrast = 'medium',
|
||||
path = '', // Make link optional for debugging purposes
|
||||
showHover = SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
countOnHover,
|
||||
hoverPhotoQueryOptions,
|
||||
hoverCount = 0,
|
||||
hoverType = 'auto',
|
||||
hoverQueryOptions,
|
||||
prefetch,
|
||||
title,
|
||||
action,
|
||||
@ -62,7 +63,6 @@ export default function EntityLink({
|
||||
prefetch?: boolean
|
||||
title?: string
|
||||
action?: ReactNode
|
||||
truncate?: boolean
|
||||
className?: string
|
||||
classNameIcon?: string
|
||||
uppercase?: boolean
|
||||
@ -85,10 +85,21 @@ export default function EntityLink({
|
||||
}
|
||||
};
|
||||
|
||||
const showHoverEntity =
|
||||
const canShowHover =
|
||||
!isLoading &&
|
||||
countOnHover &&
|
||||
!showHover;
|
||||
hoverCount > 0;
|
||||
|
||||
const showHoverImage =
|
||||
canShowHover && SHOW_CATEGORY_IMAGE_HOVERS && (
|
||||
hoverType === 'auto' ||
|
||||
hoverType === 'image'
|
||||
);
|
||||
|
||||
const showHoverText =
|
||||
canShowHover && (
|
||||
(hoverType === 'auto' && !SHOW_CATEGORY_IMAGE_HOVERS) ||
|
||||
hoverType === 'text'
|
||||
);
|
||||
|
||||
const renderLabel =
|
||||
<ResponsiveText shortText={labelSmall}>
|
||||
@ -162,14 +173,14 @@ export default function EntityLink({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showHover && countOnHover && hoverPhotoQueryOptions
|
||||
{showHoverImage
|
||||
? <EntityHover
|
||||
hoverKey={path}
|
||||
header={renderLink(true)}
|
||||
photosCount={countOnHover}
|
||||
photosCount={hoverCount}
|
||||
getPhotos={() =>
|
||||
getPhotosCachedAction({
|
||||
...hoverPhotoQueryOptions,
|
||||
...hoverQueryOptions,
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
})}
|
||||
color={contrast === 'frosted' ? 'frosted' : undefined}
|
||||
@ -181,9 +192,9 @@ export default function EntityLink({
|
||||
<span className="action">
|
||||
{action}
|
||||
</span>}
|
||||
{showHoverEntity &&
|
||||
{showHoverText &&
|
||||
<span className="hidden peer-hover:inline text-dim">
|
||||
{countOnHover}
|
||||
{hoverCount}
|
||||
</span>}
|
||||
{isLoading && !suppressSpinner &&
|
||||
<Spinner
|
||||
|
||||
9
src/components/icons/IconAlbum.tsx
Normal file
9
src/components/icons/IconAlbum.tsx
Normal 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,
|
||||
}} />;
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { PhotoSetCategory } from '../../category';
|
||||
import { PhotoSetCategory } from '@/category';
|
||||
import { Camera } from '@/camera';
|
||||
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 PHOTO_DEFAULT_LIMIT = 100;
|
||||
@ -32,14 +33,20 @@ export type PhotoQueryOptions = {
|
||||
updatedBefore?: Date
|
||||
excludeFromFeeds?: boolean
|
||||
hidden?: 'exclude' | 'include' | 'only'
|
||||
} & Omit<PhotoSetCategory, 'camera' | 'lens'> & {
|
||||
} & Omit<PhotoSetCategory, 'camera' | 'lens' | 'album'> & {
|
||||
camera?: Partial<Camera>
|
||||
lens?: Partial<Lens>
|
||||
album?: Album
|
||||
};
|
||||
|
||||
export const areOptionsSensitive = (options: PhotoQueryOptions) =>
|
||||
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 = (
|
||||
options: PhotoQueryOptions,
|
||||
initialValuesIndex = 1,
|
||||
@ -54,6 +61,7 @@ export const getWheresFromOptions = (
|
||||
maximumAspectRatio,
|
||||
recent,
|
||||
year,
|
||||
album,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
@ -129,6 +137,10 @@ export const getWheresFromOptions = (
|
||||
if (!lens.make) { wheres.push('lens_make IS NULL'); }
|
||||
wheresValues.push(parameterize(lens.model));
|
||||
}
|
||||
if (album) {
|
||||
wheres.push(`album_id=$${valuesIndex++}`);
|
||||
wheresValues.push(album.id);
|
||||
}
|
||||
if (tag) {
|
||||
wheres.push(`$${valuesIndex++}=ANY(tags)`);
|
||||
wheresValues.push(tag);
|
||||
101
src/db/query.ts
Normal file
101
src/db/query.ts
Normal 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;
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { PhotoQueryOptions } from '../photo/db';
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
import {
|
||||
INFINITE_SCROLL_FULL_INITIAL,
|
||||
INFINITE_SCROLL_GRID_INITIAL,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import { descriptionForFilmPhotos } from '.';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoFilm from '@/film/PhotoFilm';
|
||||
@ -22,7 +22,7 @@ export default function FilmHeader({
|
||||
selectedPhoto?: Photo
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
}) {
|
||||
const { recipeModalProps, setRecipeModalProps } = useAppState();
|
||||
|
||||
@ -43,7 +43,7 @@ export default function FilmHeader({
|
||||
toggleRecipeOverlay={recipeProps
|
||||
? () => setRecipeModalProps?.(recipeProps)
|
||||
: undefined}
|
||||
showHover={false}
|
||||
hoverType="none"
|
||||
/>}
|
||||
entityDescription={descriptionForFilmPhotos(
|
||||
photos,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Photo } from '../photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import ImageCaption from '@/image-response/components/ImageCaption';
|
||||
import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid';
|
||||
import ImageContainer from '@/image-response/components/ImageContainer';
|
||||
import PhotoFilmIcon from
|
||||
'@/film/PhotoFilmIcon';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import {
|
||||
pathForFilm,
|
||||
pathForFilmImage,
|
||||
@ -19,7 +19,7 @@ export default function FilmOGTile({
|
||||
film: string
|
||||
photos: Photo[]
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import FilmHeader from './FilmHeader';
|
||||
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||
|
||||
@ -12,7 +12,7 @@ export default function FilmOverview({
|
||||
film: string,
|
||||
photos: Photo[],
|
||||
count: number,
|
||||
dateRange?: PhotoDateRange,
|
||||
dateRange?: PhotoDateRangePostgres,
|
||||
animateOnFirstLoadOnly?: boolean,
|
||||
}) {
|
||||
return (
|
||||
|
||||
@ -10,6 +10,7 @@ import { labelForFilm } from '.';
|
||||
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
||||
import { ComponentProps } from 'react';
|
||||
import useCategoryCounts from '@/category/useCategoryCounts';
|
||||
|
||||
export default function PhotoFilm({
|
||||
film,
|
||||
@ -23,6 +24,8 @@ export default function PhotoFilm({
|
||||
film: string
|
||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||
& EntityLinkExternalProps) {
|
||||
const { getFilmCount } = useCategoryCounts();
|
||||
|
||||
const { small, medium, large } = labelForFilm(film);
|
||||
|
||||
return (
|
||||
@ -31,7 +34,7 @@ export default function PhotoFilm({
|
||||
label={medium}
|
||||
labelSmall={small}
|
||||
path={pathForFilm(film)}
|
||||
hoverPhotoQueryOptions={{ film }}
|
||||
hoverQueryOptions={{ film }}
|
||||
icon={<PhotoFilmIcon
|
||||
film={film}
|
||||
className={clsx(
|
||||
@ -51,6 +54,7 @@ export default function PhotoFilm({
|
||||
isShowingRecipeOverlay,
|
||||
}} />}
|
||||
iconWide={isStringFujifilmSimulation(film)}
|
||||
hoverCount={props.hoverCount ?? getFilmCount(film)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {
|
||||
Photo,
|
||||
PhotoDateRange,
|
||||
PhotoDateRangePostgres,
|
||||
descriptionForPhotoSet,
|
||||
photoQuantityText,
|
||||
} from '@/photo';
|
||||
@ -75,7 +75,7 @@ export const descriptionForFilmPhotos = (
|
||||
appText: AppTextState,
|
||||
dateBased?: boolean,
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
explicitDateRange?: PhotoDateRangePostgres,
|
||||
) =>
|
||||
descriptionForPhotoSet(
|
||||
photos,
|
||||
@ -91,7 +91,7 @@ export const generateMetaForFilm = (
|
||||
photos: Photo[],
|
||||
appText: AppTextState,
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
explicitDateRange?: PhotoDateRangePostgres,
|
||||
) => ({
|
||||
url: absolutePathForFilm(film),
|
||||
title: titleForFilm(film, photos, appText, explicitCount),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import { descriptionForFocalLengthPhotos } from '.';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoFocalLength from './PhotoFocalLength';
|
||||
@ -18,7 +18,7 @@ export default async function FocalLengthHeader({
|
||||
selectedPhoto?: Photo
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
}) {
|
||||
const appText = await getAppText();
|
||||
return (
|
||||
@ -27,7 +27,7 @@ export default async function FocalLengthHeader({
|
||||
entity={<PhotoFocalLength
|
||||
focal={focal}
|
||||
contrast="high"
|
||||
showHover={false}
|
||||
hoverType="none"
|
||||
/>}
|
||||
entityDescription={descriptionForFocalLengthPhotos(
|
||||
photos,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Photo } from '../photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
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 { formatFocalLength } from '@/focal';
|
||||
import IconFocalLength from '@/components/icons/IconFocalLength';
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import {
|
||||
pathForFocalLength,
|
||||
pathForFocalLengthImage,
|
||||
@ -19,7 +19,7 @@ export default function FocalLengthOGTile({
|
||||
focal: number
|
||||
photos: Photo[]
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
dateRange?: PhotoDateRangePostgres
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Photo, PhotoDateRangePostgres } from '@/photo';
|
||||
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||
import FocalLengthHeader from './FocalLengthHeader';
|
||||
|
||||
@ -12,7 +12,7 @@ export default function FocalLengthOverview({
|
||||
focal: number,
|
||||
photos: Photo[],
|
||||
count: number,
|
||||
dateRange?: PhotoDateRange,
|
||||
dateRange?: PhotoDateRangePostgres,
|
||||
animateOnFirstLoadOnly?: boolean,
|
||||
}) {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user