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