Date-based photo sets (#276)
* Add 'recents' and 'years' categories * Add recents and years visibility config * Add fundamental recent/year queries * Display initial date-based data in sidebar * Adjust recents data type * Remove date rage from sidebar footer * Reformat recents/years in sidebar * Organize years in grid * Rename date -> year * Add year-based views * Split sidebar years into rows * Add years to cmdk menu * Localize 'years' * Create /recents views * Enable recents share modals * Fix recents og image * Statically optimize /recents image * Don't statically optimize /recents page * Update i18n * Add recents to cmdk * Suppress spinner for year badges * Refactor sidebar height calculation * Add recents to sitemap
This commit is contained in:
parent
4698c5fe64
commit
b3972a6032
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -26,6 +26,7 @@
|
|||||||
"ghijklmnopqrstuv",
|
"ghijklmnopqrstuv",
|
||||||
"GPSH",
|
"GPSH",
|
||||||
"Hasselblad",
|
"Hasselblad",
|
||||||
|
"headerless",
|
||||||
"headlessui",
|
"headlessui",
|
||||||
"hgetall",
|
"hgetall",
|
||||||
"Hoverable",
|
"Hoverable",
|
||||||
@ -50,6 +51,7 @@
|
|||||||
"ratelimit",
|
"ratelimit",
|
||||||
"ratelimiter",
|
"ratelimiter",
|
||||||
"Reala",
|
"Reala",
|
||||||
|
"recents",
|
||||||
"skippable",
|
"skippable",
|
||||||
"sonner",
|
"sonner",
|
||||||
"sslmode",
|
"sslmode",
|
||||||
|
|||||||
@ -132,6 +132,8 @@ Application behavior can be changed by configuring the following environment var
|
|||||||
- `NEXT_PUBLIC_CATEGORY_VISIBILITY`
|
- `NEXT_PUBLIC_CATEGORY_VISIBILITY`
|
||||||
- Comma-separated value controlling which photo sets appear in grid sidebar and CMD-K menu, and in what order. For example, you could move cameras above tags, and hide film simulations, by updating to `cameras,tags,lenses,recipes`.
|
- Comma-separated value controlling which photo sets appear in grid sidebar and CMD-K menu, and in what order. For example, you could move cameras above tags, and hide film simulations, by updating to `cameras,tags,lenses,recipes`.
|
||||||
- Accepted values:
|
- Accepted values:
|
||||||
|
- `recents`
|
||||||
|
- `years`
|
||||||
- `tags` (default)
|
- `tags` (default)
|
||||||
- `cameras` (default)
|
- `cameras` (default)
|
||||||
- `lenses` (default)
|
- `lenses` (default)
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import { redirect } from 'next/navigation';
|
|||||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||||
import { getAppText } from '@/i18n/state/server';
|
import { getAppText } from '@/i18n/state/server';
|
||||||
|
|
||||||
const getPhotosFilmDataCachedCached = cache(getPhotosFilmDataCached);
|
const getPhotosFilmDataCachedCached = cache((film: string) =>
|
||||||
|
getPhotosFilmDataCached({ film, limit: INFINITE_SCROLL_GRID_INITIAL }));
|
||||||
|
|
||||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||||
'films',
|
'films',
|
||||||
@ -31,10 +32,7 @@ export async function generateMetadata({
|
|||||||
const [
|
const [
|
||||||
photos,
|
photos,
|
||||||
{ count, dateRange },
|
{ count, dateRange },
|
||||||
] = await getPhotosFilmDataCachedCached({
|
] = await getPhotosFilmDataCachedCached(film);
|
||||||
film,
|
|
||||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (photos.length === 0) { return {}; }
|
if (photos.length === 0) { return {}; }
|
||||||
|
|
||||||
@ -72,10 +70,7 @@ export default async function FilmPage({
|
|||||||
const [
|
const [
|
||||||
photos,
|
photos,
|
||||||
{ count, dateRange },
|
{ count, dateRange },
|
||||||
] = await getPhotosFilmDataCachedCached({
|
] = await getPhotosFilmDataCachedCached(film);
|
||||||
film,
|
|
||||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (photos.length === 0) { redirect(PATH_ROOT); }
|
if (photos.length === 0) { redirect(PATH_ROOT); }
|
||||||
|
|
||||||
|
|||||||
94
app/recents/[photoId]/page.tsx
Normal file
94
app/recents/[photoId]/page.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
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/paths';
|
||||||
|
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||||
|
import {
|
||||||
|
getPhotosMetaCached,
|
||||||
|
getPhotosNearIdCached,
|
||||||
|
} from '@/photo/cache';
|
||||||
|
import { cache } from 'react';
|
||||||
|
import RecentsHeader from '@/recents/RecentsHeader';
|
||||||
|
|
||||||
|
const getPhotosNearIdCachedCached = cache((photoId: string) =>
|
||||||
|
getPhotosNearIdCached(
|
||||||
|
photoId,
|
||||||
|
{ recent: true, limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 },
|
||||||
|
));
|
||||||
|
|
||||||
|
interface PhotoRecentsProps {
|
||||||
|
params: Promise<{ photoId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: PhotoRecentsProps): Promise<Metadata> {
|
||||||
|
const { photoId } = await params;
|
||||||
|
|
||||||
|
const { photo } = await getPhotosNearIdCachedCached(photoId);
|
||||||
|
|
||||||
|
if (!photo) { return {}; }
|
||||||
|
|
||||||
|
const title = titleForPhoto(photo);
|
||||||
|
const description = descriptionForPhoto(photo);
|
||||||
|
const descriptionHtml = descriptionForPhoto(photo, true);
|
||||||
|
const images = absolutePathForPhotoImage(photo);
|
||||||
|
const url = absolutePathForPhoto({ photo, recent: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: descriptionHtml,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
images,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
card: 'summary_large_image',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PhotoRecentsPage({
|
||||||
|
params,
|
||||||
|
}: PhotoRecentsProps) {
|
||||||
|
const { photoId } = await params;
|
||||||
|
|
||||||
|
const { photo, photos, photosGrid, indexNumber } =
|
||||||
|
await getPhotosNearIdCachedCached(photoId);
|
||||||
|
|
||||||
|
if (!photo) { redirect(PATH_ROOT); }
|
||||||
|
|
||||||
|
const { count, dateRange } = await getPhotosMetaCached({ recent: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhotoDetailPage {...{
|
||||||
|
photo,
|
||||||
|
photos,
|
||||||
|
photosGrid,
|
||||||
|
recent: true,
|
||||||
|
indexNumber,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
header: <RecentsHeader
|
||||||
|
photos={photos}
|
||||||
|
selectedPhoto={photo}
|
||||||
|
indexNumber={indexNumber}
|
||||||
|
count={count}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/recents/image/route.tsx
Normal file
45
app/recents/image/route.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { getPhotosCached } from '@/photo/cache';
|
||||||
|
import {
|
||||||
|
IMAGE_OG_DIMENSION_SMALL,
|
||||||
|
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||||
|
} from '@/image-response';
|
||||||
|
import RecentsImageResponse from
|
||||||
|
'@/image-response/RecentsImageResponse';
|
||||||
|
import { getIBMPlexMono } from '@/app/font';
|
||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||||
|
import { getAppText } from '@/i18n/state/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [
|
||||||
|
photos,
|
||||||
|
{ fontFamily, fonts },
|
||||||
|
headers,
|
||||||
|
] = await Promise.all([
|
||||||
|
getPhotosCached({
|
||||||
|
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||||
|
recent: true,
|
||||||
|
}),
|
||||||
|
getIBMPlexMono(),
|
||||||
|
getImageResponseCacheControlHeaders(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appText = await getAppText();
|
||||||
|
|
||||||
|
const title = appText.category.recentPlural.toLocaleUpperCase();
|
||||||
|
|
||||||
|
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<RecentsImageResponse {...{
|
||||||
|
title,
|
||||||
|
photos,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
}}/>,
|
||||||
|
{ width, height, fonts, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
63
app/recents/page.tsx
Normal file
63
app/recents/page.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||||
|
import { generateMetaForRecents } from '@/recents/meta';
|
||||||
|
import RecentsOverview from '@/recents/RecentsOverview';
|
||||||
|
import { getPhotosRecentsDataCached } from '@/recents/data';
|
||||||
|
import { Metadata } from 'next/types';
|
||||||
|
import { cache } from 'react';
|
||||||
|
import { PATH_ROOT } from '@/app/paths';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getAppText } from '@/i18n/state/server';
|
||||||
|
|
||||||
|
const getPhotosRecentsDataCachedCached = cache(() =>
|
||||||
|
getPhotosRecentsDataCached({ limit: INFINITE_SCROLL_GRID_INITIAL }));
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const [
|
||||||
|
photos,
|
||||||
|
{ count, dateRange },
|
||||||
|
] = await getPhotosRecentsDataCachedCached();
|
||||||
|
|
||||||
|
if (photos.length === 0) { return {}; }
|
||||||
|
|
||||||
|
const appText = await getAppText();
|
||||||
|
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
} = generateMetaForRecents(photos, appText, count, dateRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
images,
|
||||||
|
description,
|
||||||
|
card: 'summary_large_image',
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RecentsPage() {
|
||||||
|
const [
|
||||||
|
photos,
|
||||||
|
{ count, dateRange },
|
||||||
|
] = await getPhotosRecentsDataCachedCached();
|
||||||
|
|
||||||
|
if (photos.length === 0) { redirect(PATH_ROOT); }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecentsOverview {...{
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@ import {
|
|||||||
absolutePathForFocalLength,
|
absolutePathForFocalLength,
|
||||||
absolutePathForLens,
|
absolutePathForLens,
|
||||||
absolutePathForPhoto,
|
absolutePathForPhoto,
|
||||||
|
absolutePathForRecents,
|
||||||
absolutePathForRecipe,
|
absolutePathForRecipe,
|
||||||
absolutePathForTag,
|
absolutePathForTag,
|
||||||
|
absolutePathForYear,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
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';
|
||||||
@ -16,15 +18,17 @@ import { getPhotoIdsAndUpdatedAt } from '@/photo/db/query';
|
|||||||
// Cache for 24 hours
|
// Cache for 24 hours
|
||||||
export const revalidate = 86_400;
|
export const revalidate = 86_400;
|
||||||
|
|
||||||
const PRIORITY_HOME = 1;
|
const PRIORITY_HOME = 1;
|
||||||
const PRIORITY_HOME_VIEW = 0.9;
|
const PRIORITY_HOME_VIEW = 0.9;
|
||||||
const PRIORITY_CATEGORY_SPECIAL = 0.8;
|
const PRIORITY_CATEGORY_SPECIAL = 0.8;
|
||||||
const PRIORITY_CATEGORY = 0.7;
|
const PRIORITY_CATEGORY = 0.7;
|
||||||
const PRIORITY_PHOTO = 0.5;
|
const PRIORITY_PHOTO = 0.5;
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const [
|
const [
|
||||||
{
|
{
|
||||||
|
recents,
|
||||||
|
years,
|
||||||
cameras,
|
cameras,
|
||||||
lenses,
|
lenses,
|
||||||
tags,
|
tags,
|
||||||
@ -35,6 +39,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
photos,
|
photos,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getDataForCategoriesCached().catch(() => ({
|
getDataForCategoriesCached().catch(() => ({
|
||||||
|
recents: [],
|
||||||
|
years: [],
|
||||||
cameras: [],
|
cameras: [],
|
||||||
lenses: [],
|
lenses: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
@ -46,6 +52,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const lastModifiedSite = [
|
const lastModifiedSite = [
|
||||||
|
...recents.map(({ lastModified }) => lastModified),
|
||||||
|
...years.map(({ lastModified }) => lastModified),
|
||||||
...cameras.map(({ lastModified }) => lastModified),
|
...cameras.map(({ lastModified }) => lastModified),
|
||||||
...lenses.map(({ lastModified }) => lastModified),
|
...lenses.map(({ lastModified }) => lastModified),
|
||||||
...tags.map(({ lastModified }) => lastModified),
|
...tags.map(({ lastModified }) => lastModified),
|
||||||
@ -70,6 +78,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
priority: PRIORITY_HOME_VIEW,
|
priority: PRIORITY_HOME_VIEW,
|
||||||
lastModified: lastModifiedSite,
|
lastModified: lastModifiedSite,
|
||||||
},
|
},
|
||||||
|
// Recents
|
||||||
|
...recents.map(({ lastModified }) => ({
|
||||||
|
url: absolutePathForRecents(),
|
||||||
|
priority: PRIORITY_CATEGORY,
|
||||||
|
lastModified,
|
||||||
|
})),
|
||||||
|
// Years
|
||||||
|
...years.map(({ year, lastModified }) => ({
|
||||||
|
url: absolutePathForYear(year),
|
||||||
|
priority: PRIORITY_CATEGORY,
|
||||||
|
lastModified,
|
||||||
|
})),
|
||||||
// Cameras
|
// Cameras
|
||||||
...cameras.map(({ camera, lastModified }) => ({
|
...cameras.map(({ camera, lastModified }) => ({
|
||||||
url: absolutePathForCamera(camera),
|
url: absolutePathForCamera(camera),
|
||||||
|
|||||||
86
app/year/[year]/[photoId]/page.tsx
Normal file
86
app/year/[year]/[photoId]/page.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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/paths';
|
||||||
|
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||||
|
import {
|
||||||
|
getPhotosMetaCached,
|
||||||
|
getPhotosNearIdCached,
|
||||||
|
} from '@/photo/cache';
|
||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
const getPhotosNearIdCachedCached = cache((photoId: string, year: string) =>
|
||||||
|
getPhotosNearIdCached(
|
||||||
|
photoId,
|
||||||
|
{ year, limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 },
|
||||||
|
));
|
||||||
|
|
||||||
|
interface PhotoYearProps {
|
||||||
|
params: Promise<{ photoId: string, year: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: PhotoYearProps): Promise<Metadata> {
|
||||||
|
const { photoId, year } = await params;
|
||||||
|
|
||||||
|
const { photo } = await getPhotosNearIdCachedCached(photoId, year);
|
||||||
|
|
||||||
|
if (!photo) { return {}; }
|
||||||
|
|
||||||
|
const title = titleForPhoto(photo);
|
||||||
|
const description = descriptionForPhoto(photo);
|
||||||
|
const descriptionHtml = descriptionForPhoto(photo, true);
|
||||||
|
const images = absolutePathForPhotoImage(photo);
|
||||||
|
const url = absolutePathForPhoto({ photo, year });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: descriptionHtml,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
images,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
card: 'summary_large_image',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PhotoYearPage({
|
||||||
|
params,
|
||||||
|
}: PhotoYearProps) {
|
||||||
|
const { photoId, year } = await params;
|
||||||
|
|
||||||
|
const { photo, photos, photosGrid, indexNumber } =
|
||||||
|
await getPhotosNearIdCachedCached(photoId, year);
|
||||||
|
|
||||||
|
if (!photo) { redirect(PATH_ROOT); }
|
||||||
|
|
||||||
|
const { count, dateRange } = await getPhotosMetaCached({ year: year });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhotoDetailPage {...{
|
||||||
|
photo,
|
||||||
|
photos,
|
||||||
|
photosGrid,
|
||||||
|
year,
|
||||||
|
indexNumber,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
52
app/year/[year]/image/route.tsx
Normal file
52
app/year/[year]/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 YearImageResponse from
|
||||||
|
'@/image-response/YearImageResponse';
|
||||||
|
import { getIBMPlexMono } from '@/app/font';
|
||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||||
|
import { getUniqueYears } from '@/photo/db/query';
|
||||||
|
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||||
|
|
||||||
|
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||||
|
'years',
|
||||||
|
'image',
|
||||||
|
getUniqueYears,
|
||||||
|
years => years.map(({ year }) => ({ year })),
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_: Request,
|
||||||
|
context: { params: Promise<{ year: string }> },
|
||||||
|
) {
|
||||||
|
const { year } = await context.params;
|
||||||
|
|
||||||
|
const [
|
||||||
|
photos,
|
||||||
|
{ fontFamily, fonts },
|
||||||
|
headers,
|
||||||
|
] = await Promise.all([
|
||||||
|
getPhotosCached({
|
||||||
|
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||||
|
year: year,
|
||||||
|
}),
|
||||||
|
getIBMPlexMono(),
|
||||||
|
getImageResponseCacheControlHeaders(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<YearImageResponse {...{
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
}}/>,
|
||||||
|
{ width, height, fonts, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/year/[year]/page.tsx
Normal file
85
app/year/[year]/page.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||||
|
import { getUniqueYears } from '@/photo/db/query';
|
||||||
|
import { generateMetaForYear } from '@/years/meta';
|
||||||
|
import YearOverview from '@/years/YearOverview';
|
||||||
|
import { getPhotosYearDataCached } from '@/years/data';
|
||||||
|
import { Metadata } from 'next/types';
|
||||||
|
import { cache } from 'react';
|
||||||
|
import { PATH_ROOT } from '@/app/paths';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||||
|
import { getAppText } from '@/i18n/state/server';
|
||||||
|
|
||||||
|
const getPhotosYearDataCachedCached = cache((year: string) =>
|
||||||
|
getPhotosYearDataCached({ year, limit: INFINITE_SCROLL_GRID_INITIAL }));
|
||||||
|
|
||||||
|
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||||
|
'years',
|
||||||
|
'page',
|
||||||
|
getUniqueYears,
|
||||||
|
years => years.map(({ year }) => ({ year })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface YearProps {
|
||||||
|
params: Promise<{ year: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: YearProps): Promise<Metadata> {
|
||||||
|
const { year } = await params;
|
||||||
|
|
||||||
|
const [
|
||||||
|
photos,
|
||||||
|
{ count, dateRange },
|
||||||
|
] = await getPhotosYearDataCachedCached(year);
|
||||||
|
|
||||||
|
if (photos.length === 0) { return {}; }
|
||||||
|
|
||||||
|
const appText = await getAppText();
|
||||||
|
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
} = generateMetaForYear(year, photos, appText, count, dateRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
images,
|
||||||
|
description,
|
||||||
|
card: 'summary_large_image',
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function YearPage({
|
||||||
|
params,
|
||||||
|
}: YearProps) {
|
||||||
|
const { year } = await params;
|
||||||
|
|
||||||
|
const [
|
||||||
|
photos,
|
||||||
|
{ count, dateRange },
|
||||||
|
] = await getPhotosYearDataCachedCached(year);
|
||||||
|
|
||||||
|
if (photos.length === 0) { redirect(PATH_ROOT); }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YearOverview {...{
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -244,6 +244,12 @@ export const BLUR_ENABLED =
|
|||||||
|
|
||||||
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
|
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
|
||||||
process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY);
|
process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY);
|
||||||
|
export const SHOW_RECENTS =
|
||||||
|
CATEGORY_VISIBILITY.includes('recents');
|
||||||
|
export const IS_RECENTS_FIRST =
|
||||||
|
CATEGORY_VISIBILITY[0] === 'recents';
|
||||||
|
export const SHOW_YEARS =
|
||||||
|
CATEGORY_VISIBILITY.includes('years');
|
||||||
export const SHOW_CAMERAS =
|
export const SHOW_CAMERAS =
|
||||||
CATEGORY_VISIBILITY.includes('cameras');
|
CATEGORY_VISIBILITY.includes('cameras');
|
||||||
export const SHOW_LENSES =
|
export const SHOW_LENSES =
|
||||||
|
|||||||
@ -35,6 +35,8 @@ 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';
|
||||||
export const PREFIX_FOCAL_LENGTH = '/focal';
|
export const PREFIX_FOCAL_LENGTH = '/focal';
|
||||||
|
export const PREFIX_YEAR = '/year';
|
||||||
|
export const PREFIX_RECENTS = '/recents';
|
||||||
|
|
||||||
// Dynamic paths
|
// Dynamic paths
|
||||||
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
||||||
@ -44,6 +46,8 @@ 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]`;
|
||||||
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
|
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
|
||||||
|
const PATH_YEAR_DYNAMIC = `${PREFIX_YEAR}/[year]`;
|
||||||
|
const PATH_RECENTS_DYNAMIC = `${PREFIX_RECENTS}/[photoId]`;
|
||||||
|
|
||||||
// Admin paths
|
// Admin paths
|
||||||
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
||||||
@ -98,6 +102,8 @@ export const PATHS_TO_CACHE = [
|
|||||||
PATH_FILM_DYNAMIC,
|
PATH_FILM_DYNAMIC,
|
||||||
PATH_FOCAL_LENGTH_DYNAMIC,
|
PATH_FOCAL_LENGTH_DYNAMIC,
|
||||||
PATH_RECIPE_DYNAMIC,
|
PATH_RECIPE_DYNAMIC,
|
||||||
|
PATH_YEAR_DYNAMIC,
|
||||||
|
PATH_RECENTS_DYNAMIC,
|
||||||
...PATHS_ADMIN,
|
...PATHS_ADMIN,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -125,6 +131,8 @@ const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
|
|||||||
|
|
||||||
export const pathForPhoto = ({
|
export const pathForPhoto = ({
|
||||||
photo,
|
photo,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
camera,
|
camera,
|
||||||
lens,
|
lens,
|
||||||
tag,
|
tag,
|
||||||
@ -136,6 +144,10 @@ export const pathForPhoto = ({
|
|||||||
|
|
||||||
if (typeof photo !== 'string' && photo.hidden) {
|
if (typeof photo !== 'string' && photo.hidden) {
|
||||||
prefix = pathForTag(TAG_HIDDEN);
|
prefix = pathForTag(TAG_HIDDEN);
|
||||||
|
} else if (recent) {
|
||||||
|
prefix = PREFIX_RECENTS;
|
||||||
|
} else if (year) {
|
||||||
|
prefix = pathForYear(year);
|
||||||
} else if (camera) {
|
} else if (camera) {
|
||||||
prefix = pathForCamera(camera);
|
prefix = pathForCamera(camera);
|
||||||
} else if (lens) {
|
} else if (lens) {
|
||||||
@ -173,6 +185,9 @@ 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}`;
|
||||||
@ -198,6 +213,12 @@ export const pathForFilmImage = (film: string) =>
|
|||||||
export const pathForFocalLengthImage = (focal: number) =>
|
export const pathForFocalLengthImage = (focal: number) =>
|
||||||
pathForImage(pathForFocalLength(focal));
|
pathForImage(pathForFocalLength(focal));
|
||||||
|
|
||||||
|
export const pathForYearImage = (year: string) =>
|
||||||
|
pathForImage(pathForYear(year));
|
||||||
|
|
||||||
|
export const pathForRecentsImage = () =>
|
||||||
|
pathForImage(PREFIX_RECENTS);
|
||||||
|
|
||||||
// Absolute paths
|
// Absolute paths
|
||||||
export const ABSOLUTE_PATH_FOR_FEED_JSON =
|
export const ABSOLUTE_PATH_FOR_FEED_JSON =
|
||||||
`${getBaseUrl()}${PATH_FEED_JSON}`;
|
`${getBaseUrl()}${PATH_FEED_JSON}`;
|
||||||
@ -232,6 +253,12 @@ export const absolutePathForFilm = (film: string, share?: boolean) =>
|
|||||||
export const absolutePathForFocalLength = (focal: number, share?: boolean) =>
|
export const absolutePathForFocalLength = (focal: number, share?: boolean) =>
|
||||||
`${getBaseUrl(share)}${pathForFocalLength(focal)}`;
|
`${getBaseUrl(share)}${pathForFocalLength(focal)}`;
|
||||||
|
|
||||||
|
export const absolutePathForYear = (year: string, share?: boolean) =>
|
||||||
|
`${getBaseUrl(share)}${pathForYear(year)}`;
|
||||||
|
|
||||||
|
export const absolutePathForRecents = (share?: boolean) =>
|
||||||
|
`${getBaseUrl(share)}${PREFIX_RECENTS}`;
|
||||||
|
|
||||||
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
||||||
`${getBaseUrl()}${pathForPhotoImage(photo)}`;
|
`${getBaseUrl()}${pathForPhotoImage(photo)}`;
|
||||||
|
|
||||||
@ -253,10 +280,32 @@ export const absolutePathForFilmImage = (film: string) =>
|
|||||||
export const absolutePathForFocalLengthImage = (focal: number) =>
|
export const absolutePathForFocalLengthImage = (focal: number) =>
|
||||||
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`;
|
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`;
|
||||||
|
|
||||||
|
export const absolutePathForYearImage = (year: string, share?: boolean) =>
|
||||||
|
`${getBaseUrl(share)}${pathForYearImage(year)}`;
|
||||||
|
|
||||||
|
export const absolutePathForRecentsImage = (share?: boolean) =>
|
||||||
|
`${getBaseUrl(share)}${pathForRecentsImage()}`;
|
||||||
|
|
||||||
// p/[photoId]
|
// p/[photoId]
|
||||||
export const isPathPhoto = (pathname = '') =>
|
export const isPathPhoto = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_PHOTO}/[^/]+/?$`).test(pathname);
|
new RegExp(`^${PREFIX_PHOTO}/[^/]+/?$`).test(pathname);
|
||||||
|
|
||||||
|
// recents
|
||||||
|
export const isPathRecents = (pathname = '') =>
|
||||||
|
new RegExp(`^${PREFIX_RECENTS}/?$`).test(pathname);
|
||||||
|
|
||||||
|
// recents/[photoId]
|
||||||
|
export const isPathRecentsPhoto = (pathname = '') =>
|
||||||
|
new RegExp(`^${PREFIX_RECENTS}/[^/]+/?$`).test(pathname);
|
||||||
|
|
||||||
|
// year/[year]
|
||||||
|
export const isPathYear = (pathname = '') =>
|
||||||
|
new RegExp(`^${PREFIX_YEAR}/[^/]+/?$`).test(pathname);
|
||||||
|
|
||||||
|
// year/[year]/[photoId]
|
||||||
|
export const isPathYearPhoto = (pathname = '') =>
|
||||||
|
new RegExp(`^${PREFIX_YEAR}/[^/]+/[^/]+/?$`).test(pathname);
|
||||||
|
|
||||||
// shot-on/[make]/[model]
|
// shot-on/[make]/[model]
|
||||||
export const isPathCamera = (pathname = '') =>
|
export const isPathCamera = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
|
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
|
||||||
@ -265,6 +314,14 @@ export const isPathCamera = (pathname = '') =>
|
|||||||
export const isPathCameraPhoto = (pathname = '') =>
|
export const isPathCameraPhoto = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(pathname);
|
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(pathname);
|
||||||
|
|
||||||
|
// lens/[make]/[model]
|
||||||
|
export const isPathLens = (pathname = '') =>
|
||||||
|
new RegExp(`^${PREFIX_LENS}/[^/]+/[^/]+/?$`).test(pathname);
|
||||||
|
|
||||||
|
// lens/[make]/[model]/[photoId]
|
||||||
|
export const isPathLensPhoto = (pathname = '') =>
|
||||||
|
new RegExp(`^${PREFIX_LENS}/[^/]+/[^/]+/[^/]+/?$`).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);
|
||||||
@ -358,12 +415,19 @@ export const getPathComponents = (pathname = ''): {
|
|||||||
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
|
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
|
||||||
const photoIdFromFocalLength = pathname.match(
|
const photoIdFromFocalLength = pathname.match(
|
||||||
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
|
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
|
||||||
|
const photoIdFromYear = pathname.match(
|
||||||
|
new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1];
|
||||||
|
const photoIdFromRecents = pathname.match(
|
||||||
|
new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[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(
|
||||||
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
|
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
|
||||||
const focalString = pathname.match(
|
const focalString = pathname.match(
|
||||||
new RegExp(`^${PREFIX_FOCAL_LENGTH}/([0-9]+)mm`))?.[1];
|
new RegExp(`^${PREFIX_FOCAL_LENGTH}/([0-9]+)mm`))?.[1];
|
||||||
|
const year = pathname.match(
|
||||||
|
new RegExp(`^${PREFIX_YEAR}/([^/]+)`))?.[1];
|
||||||
|
const recent = isPathRecents(pathname) ? true : undefined;
|
||||||
|
|
||||||
const camera = cameraMake && cameraModel
|
const camera = cameraMake && cameraModel
|
||||||
? { make: cameraMake, model: cameraModel }
|
? { make: cameraMake, model: cameraModel }
|
||||||
@ -377,36 +441,56 @@ export const getPathComponents = (pathname = ''): {
|
|||||||
photoIdFromTag ||
|
photoIdFromTag ||
|
||||||
photoIdFromCamera ||
|
photoIdFromCamera ||
|
||||||
photoIdFromFilm ||
|
photoIdFromFilm ||
|
||||||
photoIdFromFocalLength
|
photoIdFromFocalLength ||
|
||||||
|
photoIdFromYear ||
|
||||||
|
photoIdFromRecents
|
||||||
),
|
),
|
||||||
tag,
|
tag,
|
||||||
camera,
|
camera,
|
||||||
film,
|
film,
|
||||||
focal,
|
focal,
|
||||||
|
year,
|
||||||
|
recent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEscapePath = (pathname?: string) => {
|
export const getEscapePath = (pathname?: string) => {
|
||||||
const {
|
const {
|
||||||
photoId,
|
photoId,
|
||||||
tag,
|
recent,
|
||||||
|
year,
|
||||||
camera,
|
camera,
|
||||||
|
lens,
|
||||||
|
tag,
|
||||||
|
recipe,
|
||||||
film,
|
film,
|
||||||
focal,
|
focal,
|
||||||
} = getPathComponents(pathname);
|
} = getPathComponents(pathname);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(photoId && isPathPhoto(pathname)) ||
|
(photoId && isPathPhoto(pathname)) ||
|
||||||
(tag && isPathTag(pathname)) ||
|
(recent && isPathRecents(pathname)) ||
|
||||||
|
(year && isPathYear(pathname)) ||
|
||||||
(camera && isPathCamera(pathname)) ||
|
(camera && isPathCamera(pathname)) ||
|
||||||
|
(lens && isPathLens(pathname)) ||
|
||||||
|
(tag && isPathTag(pathname)) ||
|
||||||
(film && isPathFilm(pathname)) ||
|
(film && isPathFilm(pathname)) ||
|
||||||
(focal && isPathFocalLength(pathname))
|
(focal && isPathFocalLength(pathname)) ||
|
||||||
|
(recipe && isPathRecipe(pathname))
|
||||||
) {
|
) {
|
||||||
return PATH_ROOT;
|
return PATH_ROOT;
|
||||||
} else if (tag && isPathTagPhoto(pathname)) {
|
} else if (recent && isPathRecentsPhoto(pathname)) {
|
||||||
return pathForTag(tag);
|
return PREFIX_RECENTS;
|
||||||
|
} else if (year && isPathYearPhoto(pathname)) {
|
||||||
|
return pathForYear(year);
|
||||||
} else if (camera && isPathCameraPhoto(pathname)) {
|
} else if (camera && isPathCameraPhoto(pathname)) {
|
||||||
return pathForCamera(camera);
|
return pathForCamera(camera);
|
||||||
|
} else if (lens && isPathLensPhoto(pathname)) {
|
||||||
|
return pathForLens(lens);
|
||||||
|
} else if (tag && isPathTagPhoto(pathname)) {
|
||||||
|
return pathForTag(tag);
|
||||||
|
} else if (recipe && isPathRecipePhoto(pathname)) {
|
||||||
|
return pathForRecipe(recipe);
|
||||||
} else if (film && isPathFilmPhoto(pathname)) {
|
} else if (film && isPathFilmPhoto(pathname)) {
|
||||||
return pathForFilm(film);
|
return pathForFilm(film);
|
||||||
} else if (focal && isPathFocalLengthPhoto(pathname)) {
|
} else if (focal && isPathFocalLengthPhoto(pathname)) {
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
getPhotosMeta,
|
||||||
getUniqueCameras,
|
getUniqueCameras,
|
||||||
getUniqueFilms,
|
getUniqueFilms,
|
||||||
getUniqueFocalLengths,
|
getUniqueFocalLengths,
|
||||||
getUniqueLenses,
|
getUniqueLenses,
|
||||||
getUniqueRecipes,
|
getUniqueRecipes,
|
||||||
getUniqueTags,
|
getUniqueTags,
|
||||||
|
getUniqueYears,
|
||||||
} from '@/photo/db/query';
|
} from '@/photo/db/query';
|
||||||
import {
|
import {
|
||||||
SHOW_FILMS,
|
SHOW_FILMS,
|
||||||
@ -13,6 +15,8 @@ import {
|
|||||||
SHOW_RECIPES,
|
SHOW_RECIPES,
|
||||||
SHOW_CAMERAS,
|
SHOW_CAMERAS,
|
||||||
SHOW_TAGS,
|
SHOW_TAGS,
|
||||||
|
SHOW_YEARS,
|
||||||
|
SHOW_RECENTS,
|
||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import { createLensKey } from '@/lens';
|
import { createLensKey } from '@/lens';
|
||||||
import { sortTagsByCount } from '@/tag';
|
import { sortTagsByCount } from '@/tag';
|
||||||
@ -22,6 +26,8 @@ import { sortFocalLengths } from '@/focal';
|
|||||||
type CategoryData = Awaited<ReturnType<typeof getDataForCategories>>;
|
type CategoryData = Awaited<ReturnType<typeof getDataForCategories>>;
|
||||||
|
|
||||||
export const NULL_CATEGORY_DATA: CategoryData = {
|
export const NULL_CATEGORY_DATA: CategoryData = {
|
||||||
|
recents: [],
|
||||||
|
years: [],
|
||||||
cameras: [],
|
cameras: [],
|
||||||
lenses: [],
|
lenses: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
@ -31,6 +37,18 @@ export const NULL_CATEGORY_DATA: CategoryData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getDataForCategories = () => Promise.all([
|
export const getDataForCategories = () => Promise.all([
|
||||||
|
SHOW_RECENTS
|
||||||
|
? getPhotosMeta({ recent: true })
|
||||||
|
.then(({ count, dateRange }) => [{
|
||||||
|
count,
|
||||||
|
lastModified: new Date(dateRange?.end ?? ''),
|
||||||
|
}])
|
||||||
|
.catch(() => [])
|
||||||
|
: undefined,
|
||||||
|
SHOW_YEARS
|
||||||
|
? getUniqueYears()
|
||||||
|
.catch(() => [])
|
||||||
|
: undefined,
|
||||||
SHOW_CAMERAS
|
SHOW_CAMERAS
|
||||||
? getUniqueCameras()
|
? getUniqueCameras()
|
||||||
.then(sortCategoriesByCount)
|
.then(sortCategoriesByCount)
|
||||||
@ -62,6 +80,8 @@ export const getDataForCategories = () => Promise.all([
|
|||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
: undefined,
|
: undefined,
|
||||||
]).then(([
|
]).then(([
|
||||||
|
recents = [],
|
||||||
|
years = [],
|
||||||
cameras = [],
|
cameras = [],
|
||||||
lenses = [],
|
lenses = [],
|
||||||
tags = [],
|
tags = [],
|
||||||
@ -69,11 +89,20 @@ export const getDataForCategories = () => Promise.all([
|
|||||||
films = [],
|
films = [],
|
||||||
focalLengths = [],
|
focalLengths = [],
|
||||||
]) => ({
|
]) => ({
|
||||||
cameras, lenses, tags, recipes, films, focalLengths,
|
recents,
|
||||||
|
years,
|
||||||
|
cameras,
|
||||||
|
lenses,
|
||||||
|
tags,
|
||||||
|
recipes,
|
||||||
|
films,
|
||||||
|
focalLengths,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const getCountsForCategories = async () => {
|
export const getCountsForCategories = async () => {
|
||||||
const {
|
const {
|
||||||
|
recents,
|
||||||
|
years,
|
||||||
cameras,
|
cameras,
|
||||||
lenses,
|
lenses,
|
||||||
tags,
|
tags,
|
||||||
@ -83,6 +112,13 @@ export const getCountsForCategories = async () => {
|
|||||||
} = await getDataForCategories();
|
} = await getDataForCategories();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
recents: recents[0]?.count
|
||||||
|
? { count: recents[0].count }
|
||||||
|
: {} as Record<string, number>,
|
||||||
|
years: years.reduce((acc, year) => {
|
||||||
|
acc[year.year] = year.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;
|
||||||
|
|||||||
@ -6,8 +6,12 @@ import { Lens, Lenses } from '@/lens';
|
|||||||
import { Tags } from '@/tag';
|
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 { Years } from '@/years';
|
||||||
|
|
||||||
const CATEGORY_KEYS = [
|
const CATEGORY_KEYS = [
|
||||||
|
'recents',
|
||||||
|
'years',
|
||||||
'cameras',
|
'cameras',
|
||||||
'lenses',
|
'lenses',
|
||||||
'tags',
|
'tags',
|
||||||
@ -40,6 +44,8 @@ export const getHiddenDefaultCategories = (keys: CategoryKeys): CategoryKeys =>
|
|||||||
DEFAULT_CATEGORY_KEYS.filter(key => !keys.includes(key));
|
DEFAULT_CATEGORY_KEYS.filter(key => !keys.includes(key));
|
||||||
|
|
||||||
export interface PhotoSetCategory {
|
export interface PhotoSetCategory {
|
||||||
|
recent?: boolean
|
||||||
|
year?: string
|
||||||
camera?: Camera
|
camera?: Camera
|
||||||
lens?: Lens
|
lens?: Lens
|
||||||
tag?: string
|
tag?: string
|
||||||
@ -55,6 +61,8 @@ export interface PhotoSetCategories {
|
|||||||
recipes: Recipes
|
recipes: Recipes
|
||||||
films: Films
|
films: Films
|
||||||
focalLengths: FocalLengths
|
focalLengths: FocalLengths
|
||||||
|
years: Years
|
||||||
|
recents: Recents
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhotoSetAttributes {
|
export interface PhotoSetAttributes {
|
||||||
|
|||||||
@ -30,6 +30,8 @@ import {
|
|||||||
pathForPhoto,
|
pathForPhoto,
|
||||||
pathForRecipe,
|
pathForRecipe,
|
||||||
pathForTag,
|
pathForTag,
|
||||||
|
pathForYear,
|
||||||
|
PREFIX_RECENTS,
|
||||||
} from '../app/paths';
|
} from '../app/paths';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
@ -42,8 +44,6 @@ import { IoInvertModeSharp } from 'react-icons/io5';
|
|||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { searchPhotosAction } from '@/photo/actions';
|
import { searchPhotosAction } from '@/photo/actions';
|
||||||
import { RiToolsFill } from 'react-icons/ri';
|
import { RiToolsFill } from 'react-icons/ri';
|
||||||
import { BiSolidUser } from 'react-icons/bi';
|
|
||||||
import { HiDocumentText } from 'react-icons/hi';
|
|
||||||
import { signOutAction } from '@/auth/actions';
|
import { signOutAction } from '@/auth/actions';
|
||||||
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
|
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
|
||||||
import PhotoDate from '@/photo/PhotoDate';
|
import PhotoDate from '@/photo/PhotoDate';
|
||||||
@ -79,6 +79,7 @@ import IconRecipe from '../components/icons/IconRecipe';
|
|||||||
import IconFocalLength from '../components/icons/IconFocalLength';
|
import IconFocalLength from '../components/icons/IconFocalLength';
|
||||||
import IconFilm from '../components/icons/IconFilm';
|
import IconFilm from '../components/icons/IconFilm';
|
||||||
import IconLock from '../components/icons/IconLock';
|
import IconLock from '../components/icons/IconLock';
|
||||||
|
import IconYear from '../components/icons/IconYear';
|
||||||
import useVisualViewportHeight from '@/utility/useVisualViewport';
|
import useVisualViewportHeight from '@/utility/useVisualViewport';
|
||||||
import useMaskedScroll from '../components/useMaskedScroll';
|
import useMaskedScroll from '../components/useMaskedScroll';
|
||||||
import { labelForFilm } from '@/film';
|
import { labelForFilm } from '@/film';
|
||||||
@ -86,6 +87,10 @@ import IconFavs from '@/components/icons/IconFavs';
|
|||||||
import IconHidden from '@/components/icons/IconHidden';
|
import IconHidden from '@/components/icons/IconHidden';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
|
import IconRecents from '@/components/icons/IconRecents';
|
||||||
|
import { CgFileDocument } from 'react-icons/cg';
|
||||||
|
import { FaRegUserCircle } from 'react-icons/fa';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
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';
|
||||||
@ -123,6 +128,8 @@ const renderToggle = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function CommandKClient({
|
export default function CommandKClient({
|
||||||
|
recents,
|
||||||
|
years: _years,
|
||||||
cameras,
|
cameras,
|
||||||
lenses,
|
lenses,
|
||||||
tags: _tags,
|
tags: _tags,
|
||||||
@ -289,6 +296,20 @@ export default function CommandKClient({
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const recent = recents[0];
|
||||||
|
const recentsStatus = useMemo(() => {
|
||||||
|
if (!recent) { return undefined; }
|
||||||
|
const { count, lastModified } = recent;
|
||||||
|
const subhead = appText.category.recentSubhead(
|
||||||
|
formatDistanceToNow(lastModified),
|
||||||
|
);
|
||||||
|
return count ? { count, subhead } : undefined;
|
||||||
|
}, [recent, appText]);
|
||||||
|
|
||||||
|
const years = useMemo(() =>
|
||||||
|
_years.filter(({ year }) => queryLive && year.includes(queryLive))
|
||||||
|
, [_years, queryLive]);
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
const tagsIncludingHidden = photosCountHidden > 0
|
const tagsIncludingHidden = photosCountHidden > 0
|
||||||
? addHiddenToTags(_tags, photosCountHidden)
|
? addHiddenToTags(_tags, photosCountHidden)
|
||||||
@ -302,6 +323,26 @@ export default function CommandKClient({
|
|||||||
CATEGORY_VISIBILITY
|
CATEGORY_VISIBILITY
|
||||||
.map(category => {
|
.map(category => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
|
case 'recents': return {
|
||||||
|
heading: appText.category.recentPlural,
|
||||||
|
accessory: <IconRecents size={15} />,
|
||||||
|
items: recentsStatus ? [{
|
||||||
|
label: recentsStatus.subhead,
|
||||||
|
annotation: formatCount(recentsStatus.count),
|
||||||
|
annotationAria: formatCountDescriptive(recentsStatus.count),
|
||||||
|
path: PREFIX_RECENTS,
|
||||||
|
}] : [],
|
||||||
|
};
|
||||||
|
case 'years': return {
|
||||||
|
heading: appText.category.yearPlural,
|
||||||
|
accessory: <IconYear size={14} />,
|
||||||
|
items: years.map(({ year, count }) => ({
|
||||||
|
label: year,
|
||||||
|
annotation: formatCount(count),
|
||||||
|
annotationAria: formatCountDescriptive(count),
|
||||||
|
path: pathForYear(year),
|
||||||
|
})),
|
||||||
|
};
|
||||||
case 'cameras': return {
|
case 'cameras': return {
|
||||||
heading: appText.category.cameraPlural,
|
heading: appText.category.cameraPlural,
|
||||||
accessory: <IconCamera size={14} />,
|
accessory: <IconCamera size={14} />,
|
||||||
@ -388,9 +429,11 @@ export default function CommandKClient({
|
|||||||
.filter(Boolean) as CommandKSection[]
|
.filter(Boolean) as CommandKSection[]
|
||||||
, [
|
, [
|
||||||
appText,
|
appText,
|
||||||
tags,
|
recentsStatus,
|
||||||
|
years,
|
||||||
cameras,
|
cameras,
|
||||||
lenses,
|
lenses,
|
||||||
|
tags,
|
||||||
recipes,
|
recipes,
|
||||||
films,
|
films,
|
||||||
focalLengths,
|
focalLengths,
|
||||||
@ -481,13 +524,16 @@ export default function CommandKClient({
|
|||||||
|
|
||||||
const sectionPages: CommandKSection = {
|
const sectionPages: CommandKSection = {
|
||||||
heading: 'Pages',
|
heading: 'Pages',
|
||||||
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
|
accessory: <CgFileDocument size={14} className="translate-x-[-0.5px]" />,
|
||||||
items: pageItems,
|
items: pageItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminSection: CommandKSection = {
|
const adminSection: CommandKSection = {
|
||||||
heading: 'Admin',
|
heading: 'Admin',
|
||||||
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
|
accessory: <FaRegUserCircle
|
||||||
|
size={13}
|
||||||
|
className="translate-x-[-0.5px] translate-y-[0.5px]"
|
||||||
|
/>,
|
||||||
items: [],
|
items: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { COLLAPSE_SIDEBAR_CATEGORIES } from '@/app/config';
|
|||||||
|
|
||||||
export default function HeaderList({
|
export default function HeaderList({
|
||||||
title,
|
title,
|
||||||
className,
|
className = 'space-y-1',
|
||||||
icon,
|
icon,
|
||||||
items,
|
items,
|
||||||
maxItems = 5,
|
maxItems = 5,
|
||||||
@ -29,10 +29,7 @@ export default function HeaderList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimateItems
|
<AnimateItems
|
||||||
className={clsx(
|
className={className}
|
||||||
'space-y-1',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
scaleOffset={0.95}
|
scaleOffset={0.95}
|
||||||
duration={0.5}
|
duration={0.5}
|
||||||
staggerDelay={0.05}
|
staggerDelay={0.05}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export default function MaskedScroll({
|
|||||||
setMaxSize,
|
setMaxSize,
|
||||||
hideScrollbar,
|
hideScrollbar,
|
||||||
updateMaskOnEvents,
|
updateMaskOnEvents,
|
||||||
|
updateMaskAfterDelay,
|
||||||
scrollToEndOnMount,
|
scrollToEndOnMount,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
@ -27,6 +28,7 @@ Omit<Parameters<typeof useMaskedScroll>[0], 'ref'> &
|
|||||||
setMaxSize,
|
setMaxSize,
|
||||||
hideScrollbar,
|
hideScrollbar,
|
||||||
updateMaskOnEvents,
|
updateMaskOnEvents,
|
||||||
|
updateMaskAfterDelay,
|
||||||
scrollToEndOnMount,
|
scrollToEndOnMount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
src/components/icons/IconRecents.tsx
Normal file
12
src/components/icons/IconRecents.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IconBaseProps } from 'react-icons';
|
||||||
|
import { HiLightningBolt } from 'react-icons/hi';
|
||||||
|
import { TbBolt } from 'react-icons/tb';
|
||||||
|
|
||||||
|
export default function IconRecents({
|
||||||
|
solid,
|
||||||
|
...props
|
||||||
|
}: IconBaseProps & { solid?: boolean}) {
|
||||||
|
return solid
|
||||||
|
? <HiLightningBolt {...props} />
|
||||||
|
: <TbBolt {...props} />;
|
||||||
|
}
|
||||||
6
src/components/icons/IconYear.tsx
Normal file
6
src/components/icons/IconYear.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IconBaseProps } from 'react-icons';
|
||||||
|
import { LuCalendarDays } from 'react-icons/lu';
|
||||||
|
|
||||||
|
export default function IconYear(props: IconBaseProps) {
|
||||||
|
return <LuCalendarDays {...props} />;
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ export default function ImageWithFallback({
|
|||||||
classNameImage = 'object-cover h-full',
|
classNameImage = 'object-cover h-full',
|
||||||
blurDataURL,
|
blurDataURL,
|
||||||
blurCompatibilityLevel = 'low',
|
blurCompatibilityLevel = 'low',
|
||||||
|
priority,
|
||||||
...props
|
...props
|
||||||
}: ImageProps & {
|
}: ImageProps & {
|
||||||
blurCompatibilityLevel?: 'none' | 'low' | 'high'
|
blurCompatibilityLevel?: 'none' | 'low' | 'high'
|
||||||
@ -57,6 +58,7 @@ export default function ImageWithFallback({
|
|||||||
>
|
>
|
||||||
<Image {...{
|
<Image {...{
|
||||||
...props,
|
...props,
|
||||||
|
priority,
|
||||||
className: classNameImage,
|
className: classNameImage,
|
||||||
onLoad,
|
onLoad,
|
||||||
onError,
|
onError,
|
||||||
|
|||||||
@ -18,12 +18,14 @@ export interface EntityLinkExternalProps {
|
|||||||
showTooltip?: boolean
|
showTooltip?: boolean
|
||||||
uppercase?: boolean
|
uppercase?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
|
suppressSpinner?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EntityLink({
|
export default function EntityLink({
|
||||||
ref,
|
ref,
|
||||||
icon,
|
icon,
|
||||||
|
iconBadge,
|
||||||
label,
|
label,
|
||||||
labelSmall,
|
labelSmall,
|
||||||
labelComplex,
|
labelComplex,
|
||||||
@ -43,9 +45,11 @@ export default function EntityLink({
|
|||||||
className,
|
className,
|
||||||
classNameIcon,
|
classNameIcon,
|
||||||
uppercase,
|
uppercase,
|
||||||
|
suppressSpinner,
|
||||||
debug,
|
debug,
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
|
iconBadge?: ReactNode
|
||||||
label: string
|
label: string
|
||||||
labelSmall?: ReactNode
|
labelSmall?: ReactNode
|
||||||
labelComplex?: ReactNode
|
labelComplex?: ReactNode
|
||||||
@ -115,10 +119,14 @@ export default function EntityLink({
|
|||||||
? <Badge
|
? <Badge
|
||||||
type="small"
|
type="small"
|
||||||
contrast={contrast}
|
contrast={contrast}
|
||||||
className="translate-y-[-0.5px]"
|
className={clsx(
|
||||||
|
'translate-y-[-0.5px]',
|
||||||
|
iconBadge && '*:flex *:items-center *:gap-1',
|
||||||
|
)}
|
||||||
uppercase
|
uppercase
|
||||||
interactive
|
interactive
|
||||||
>
|
>
|
||||||
|
{iconBadge}
|
||||||
{renderLabel}
|
{renderLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
: <span className={clsx(
|
: <span className={clsx(
|
||||||
@ -160,7 +168,7 @@ export default function EntityLink({
|
|||||||
<span className="hidden peer-hover:inline text-dim">
|
<span className="hidden peer-hover:inline text-dim">
|
||||||
{hoverEntity}
|
{hoverEntity}
|
||||||
</span>}
|
</span>}
|
||||||
{isLoading &&
|
{isLoading && !suppressSpinner &&
|
||||||
<Spinner
|
<Spinner
|
||||||
className={clsx(
|
className={clsx(
|
||||||
badged && 'translate-y-[0.5px]',
|
badged && 'translate-y-[0.5px]',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export default function useMaskedScroll({
|
|||||||
hideScrollbar = true,
|
hideScrollbar = true,
|
||||||
// Disable when calling 'updateMask' explicitly
|
// Disable when calling 'updateMask' explicitly
|
||||||
updateMaskOnEvents = true,
|
updateMaskOnEvents = true,
|
||||||
|
updateMaskAfterDelay = 0,
|
||||||
scrollToEndOnMount,
|
scrollToEndOnMount,
|
||||||
}: {
|
}: {
|
||||||
ref: RefObject<HTMLDivElement | null>
|
ref: RefObject<HTMLDivElement | null>
|
||||||
@ -27,6 +28,7 @@ export default function useMaskedScroll({
|
|||||||
animationDuration?: number
|
animationDuration?: number
|
||||||
setMaxSize?: boolean
|
setMaxSize?: boolean
|
||||||
hideScrollbar?: boolean
|
hideScrollbar?: boolean
|
||||||
|
updateMaskAfterDelay?: number
|
||||||
scrollToEndOnMount?: boolean
|
scrollToEndOnMount?: boolean
|
||||||
}) {
|
}) {
|
||||||
const isVertical = direction === 'vertical';
|
const isVertical = direction === 'vertical';
|
||||||
@ -50,19 +52,25 @@ export default function useMaskedScroll({
|
|||||||
}, [containerRef, isVertical]);
|
}, [containerRef, isVertical]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Conditionally track events
|
||||||
const ref = containerRef?.current;
|
const ref = containerRef?.current;
|
||||||
if (ref) {
|
if (ref && updateMaskOnEvents) {
|
||||||
updateMask();
|
ref.onscroll = updateMask;
|
||||||
if (updateMaskOnEvents) {
|
ref.onresize = updateMask;
|
||||||
ref.onscroll = updateMask;
|
return () => {
|
||||||
ref.onresize = updateMask;
|
ref.onscroll = null;
|
||||||
return () => {
|
ref.onresize = null;
|
||||||
ref.onscroll = null;
|
};
|
||||||
ref.onresize = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [containerRef, updateMask, updateMaskOnEvents]);
|
if (updateMaskAfterDelay) {
|
||||||
|
// Update after delay
|
||||||
|
const timeout = setTimeout(updateMask, updateMaskAfterDelay);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
} else {
|
||||||
|
// Update on mount
|
||||||
|
updateMask();
|
||||||
|
}
|
||||||
|
}, [containerRef, updateMask, updateMaskOnEvents, updateMaskAfterDelay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = containerRef?.current;
|
const ref = containerRef?.current;
|
||||||
|
|||||||
@ -5,10 +5,6 @@ import locale from './date-fns-locale-alias';
|
|||||||
|
|
||||||
export type I18N = typeof EN_US;
|
export type I18N = typeof EN_US;
|
||||||
|
|
||||||
export type I18NDeepPartial = {
|
|
||||||
[key in keyof I18N]?: Partial<I18N[key]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TRANSLATION STEPS FOR CONTRIBUTORS:
|
* TRANSLATION STEPS FOR CONTRIBUTORS:
|
||||||
* 1. Create new file in `src/i18n/locales` modeled on `en-us.ts`—
|
* 1. Create new file in `src/i18n/locales` modeled on `en-us.ts`—
|
||||||
@ -20,7 +16,7 @@ export type I18NDeepPartial = {
|
|||||||
|
|
||||||
const LOCALE_TEXT_IMPORTS: Record<
|
const LOCALE_TEXT_IMPORTS: Record<
|
||||||
string,
|
string,
|
||||||
() => Promise<I18NDeepPartial | undefined>
|
() => Promise<I18N | undefined>
|
||||||
> = {
|
> = {
|
||||||
'pt-br': () => import('./locales/pt-br').then(m => m.TEXT),
|
'pt-br': () => import('./locales/pt-br').then(m => m.TEXT),
|
||||||
'pt-pt': () => import('./locales/pt-pt').then(m => m.TEXT),
|
'pt-pt': () => import('./locales/pt-pt').then(m => m.TEXT),
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import { I18N } from '..';
|
||||||
|
|
||||||
export { bn as default } from 'date-fns/locale/bn';
|
export { bn as default } from 'date-fns/locale/bn';
|
||||||
|
|
||||||
export const TEXT = {
|
export const TEXT: I18N = {
|
||||||
photo: {
|
photo: {
|
||||||
photo: 'ছবি',
|
photo: 'ছবি',
|
||||||
photoPlural: 'ছবিগুলো',
|
photoPlural: 'ছবিগুলো',
|
||||||
@ -31,6 +33,14 @@ export const TEXT = {
|
|||||||
focalLengthPlural: 'ফোকাল দৈর্ঘ্যগুলো',
|
focalLengthPlural: 'ফোকাল দৈর্ঘ্যগুলো',
|
||||||
focalLengthTitle: '{{focal}} ফোকাল দৈর্ঘ্য',
|
focalLengthTitle: '{{focal}} ফোকাল দৈর্ঘ্য',
|
||||||
focalLengthShare: '{{focal}} এ তোলা ছবিগুলো',
|
focalLengthShare: '{{focal}} এ তোলা ছবিগুলো',
|
||||||
|
year: 'বছর',
|
||||||
|
yearPlural: 'বছরসমূহ',
|
||||||
|
yearShare: '{{year}} ছবি',
|
||||||
|
yearTitle: '{{year}} সালে তোলা ছবি',
|
||||||
|
recent: 'সাম্প্রতিক',
|
||||||
|
recentPlural: 'সাম্প্রতিক',
|
||||||
|
recentTitle: 'সাম্প্রতিক ছবি',
|
||||||
|
recentSubhead: '{{distance}} আগে আপলোড হয়েছে',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'হোম',
|
home: 'হোম',
|
||||||
|
|||||||
@ -31,6 +31,14 @@ export const TEXT = {
|
|||||||
focalLengthPlural: 'Focal Lengths',
|
focalLengthPlural: 'Focal Lengths',
|
||||||
focalLengthTitle: 'Focal Length {{focal}}',
|
focalLengthTitle: 'Focal Length {{focal}}',
|
||||||
focalLengthShare: 'Photos shot at {{focal}}',
|
focalLengthShare: 'Photos shot at {{focal}}',
|
||||||
|
year: 'Year',
|
||||||
|
yearPlural: 'Years',
|
||||||
|
yearShare: '{{year}} photos',
|
||||||
|
yearTitle: 'Photos taken in {{year}}',
|
||||||
|
recent: 'Recent',
|
||||||
|
recentPlural: 'Recents',
|
||||||
|
recentTitle: 'Recent Photos',
|
||||||
|
recentSubhead: 'Uploaded {{distance}} ago',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { I18NDeepPartial } from '..';
|
import { I18N } from '..';
|
||||||
export { id as default } from 'date-fns/locale/id';
|
export { id as default } from 'date-fns/locale/id';
|
||||||
|
|
||||||
export const TEXT: I18NDeepPartial = {
|
export const TEXT: I18N = {
|
||||||
photo: {
|
photo: {
|
||||||
photo: 'Foto',
|
photo: 'Foto',
|
||||||
photoPlural: 'Foto',
|
photoPlural: 'Foto',
|
||||||
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
|
|||||||
focalLengthPlural: 'Panjang Fokus',
|
focalLengthPlural: 'Panjang Fokus',
|
||||||
focalLengthTitle: 'Panjang Fokus {{focal}}',
|
focalLengthTitle: 'Panjang Fokus {{focal}}',
|
||||||
focalLengthShare: 'Foto diambil pada {{focal}}',
|
focalLengthShare: 'Foto diambil pada {{focal}}',
|
||||||
|
year: 'Tahun',
|
||||||
|
yearPlural: 'Tahun',
|
||||||
|
yearShare: 'Foto {{year}}',
|
||||||
|
yearTitle: 'Foto diambil pada tahun {{year}}',
|
||||||
|
recent: 'Terbaru',
|
||||||
|
recentPlural: 'Terbaru',
|
||||||
|
recentTitle: 'Foto Terbaru',
|
||||||
|
recentSubhead: 'Diunggah {{distance}} yang lalu',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Beranda',
|
home: 'Beranda',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { I18NDeepPartial } from '..';
|
import { I18N } from '..';
|
||||||
export { ptBR as default } from 'date-fns/locale/pt-BR';
|
export { ptBR as default } from 'date-fns/locale/pt-BR';
|
||||||
|
|
||||||
export const TEXT: I18NDeepPartial = {
|
export const TEXT: I18N = {
|
||||||
photo: {
|
photo: {
|
||||||
photo: 'Foto',
|
photo: 'Foto',
|
||||||
photoPlural: 'Fotos',
|
photoPlural: 'Fotos',
|
||||||
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
|
|||||||
focalLengthPlural: 'Distâncias focais',
|
focalLengthPlural: 'Distâncias focais',
|
||||||
focalLengthTitle: 'Distância focal {{focal}}',
|
focalLengthTitle: 'Distância focal {{focal}}',
|
||||||
focalLengthShare: 'Fotos tiradas em {{focal}}',
|
focalLengthShare: 'Fotos tiradas em {{focal}}',
|
||||||
|
year: 'Ano',
|
||||||
|
yearPlural: 'Anos',
|
||||||
|
yearShare: 'Fotos de {{year}}',
|
||||||
|
yearTitle: 'Fotos tiradas em {{year}}',
|
||||||
|
recent: 'Recente',
|
||||||
|
recentPlural: 'Recentes',
|
||||||
|
recentTitle: 'Fotos Recentes',
|
||||||
|
recentSubhead: 'Enviado há {{distance}}',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Início',
|
home: 'Início',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { I18NDeepPartial } from '..';
|
import { I18N } from '..';
|
||||||
export { pt as default } from 'date-fns/locale/pt';
|
export { pt as default } from 'date-fns/locale/pt';
|
||||||
|
|
||||||
export const TEXT: I18NDeepPartial = {
|
export const TEXT: I18N = {
|
||||||
photo: {
|
photo: {
|
||||||
photo: 'Fotografia',
|
photo: 'Fotografia',
|
||||||
photoPlural: 'Fotografias',
|
photoPlural: 'Fotografias',
|
||||||
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
|
|||||||
focalLengthPlural: 'Distâncias focais',
|
focalLengthPlural: 'Distâncias focais',
|
||||||
focalLengthTitle: 'Distância focal {{focal}}',
|
focalLengthTitle: 'Distância focal {{focal}}',
|
||||||
focalLengthShare: 'Fotos tiradas em {{focal}}',
|
focalLengthShare: 'Fotos tiradas em {{focal}}',
|
||||||
|
year: 'Ano',
|
||||||
|
yearPlural: 'Anos',
|
||||||
|
yearShare: 'Fotos de {{year}}',
|
||||||
|
yearTitle: 'Fotos tiradas em {{year}}',
|
||||||
|
recent: 'Recente',
|
||||||
|
recentPlural: 'Recentes',
|
||||||
|
recentTitle: 'Fotos Recentes',
|
||||||
|
recentSubhead: 'Enviado há {{distance}}',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Início',
|
home: 'Início',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { I18NDeepPartial } from '..';
|
import { I18N } from '..';
|
||||||
export { zhCN as default } from 'date-fns/locale/zh-CN';
|
export { zhCN as default } from 'date-fns/locale/zh-CN';
|
||||||
|
|
||||||
export const TEXT: I18NDeepPartial = {
|
export const TEXT: I18N = {
|
||||||
photo: {
|
photo: {
|
||||||
photo: '照片',
|
photo: '照片',
|
||||||
photoPlural: '照片',
|
photoPlural: '照片',
|
||||||
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
|
|||||||
focalLengthPlural: '焦距',
|
focalLengthPlural: '焦距',
|
||||||
focalLengthTitle: '焦距 {{focal}}',
|
focalLengthTitle: '焦距 {{focal}}',
|
||||||
focalLengthShare: '焦距 {{focal}} 拍摄的照片',
|
focalLengthShare: '焦距 {{focal}} 拍摄的照片',
|
||||||
|
year: '年份',
|
||||||
|
yearPlural: '年份',
|
||||||
|
yearShare: '{{year}} 照片',
|
||||||
|
yearTitle: '{{year}} 年拍摄的照片',
|
||||||
|
recent: '最近',
|
||||||
|
recentPlural: '最近',
|
||||||
|
recentTitle: '最近的照片',
|
||||||
|
recentSubhead: '{{distance}} 前上传',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: '首页',
|
home: '首页',
|
||||||
|
|||||||
@ -7,6 +7,10 @@ export const generateAppTextState = (i18n: I18N) => {
|
|||||||
...i18n,
|
...i18n,
|
||||||
category: {
|
category: {
|
||||||
...i18n.category,
|
...i18n.category,
|
||||||
|
yearTitle: (year: string) =>
|
||||||
|
i18n.category.yearTitle.replace('{{year}}', year),
|
||||||
|
yearShare: (year: string) =>
|
||||||
|
i18n.category.yearShare.replace('{{year}}', year),
|
||||||
cameraTitle: (camera: string) =>
|
cameraTitle: (camera: string) =>
|
||||||
i18n.category.cameraTitle.replace('{{camera}}', camera),
|
i18n.category.cameraTitle.replace('{{camera}}', camera),
|
||||||
cameraShare: (camera: string) =>
|
cameraShare: (camera: string) =>
|
||||||
@ -21,6 +25,8 @@ export const generateAppTextState = (i18n: I18N) => {
|
|||||||
i18n.category.focalLengthTitle.replace('{{focal}}', focal),
|
i18n.category.focalLengthTitle.replace('{{focal}}', focal),
|
||||||
focalLengthShare: (focal: string) =>
|
focalLengthShare: (focal: string) =>
|
||||||
i18n.category.focalLengthShare.replace('{{focal}}', focal),
|
i18n.category.focalLengthShare.replace('{{focal}}', focal),
|
||||||
|
recentSubhead: (distance: string) =>
|
||||||
|
i18n.category.recentSubhead.replace('{{distance}}', distance),
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
...i18n.admin,
|
...i18n.admin,
|
||||||
|
|||||||
45
src/image-response/RecentsImageResponse.tsx
Normal file
45
src/image-response/RecentsImageResponse.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Photo } from '@/photo';
|
||||||
|
import ImageCaption from './components/ImageCaption';
|
||||||
|
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||||
|
import ImageContainer from './components/ImageContainer';
|
||||||
|
import { NextImageSize } from '@/platforms/next-image';
|
||||||
|
import IconRecents from '@/components/icons/IconRecents';
|
||||||
|
|
||||||
|
export default function RecentsImageResponse({
|
||||||
|
title,
|
||||||
|
photos,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
photos: Photo[]
|
||||||
|
width: NextImageSize
|
||||||
|
height: number
|
||||||
|
fontFamily: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ImageContainer solidBackground={photos.length === 0}>
|
||||||
|
<ImagePhotoGrid
|
||||||
|
{...{
|
||||||
|
photos,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ImageCaption {...{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
icon: <IconRecents
|
||||||
|
size={height * .08}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${height * .003}px)`,
|
||||||
|
marginRight: height * .01,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
title,
|
||||||
|
}} />
|
||||||
|
</ImageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/image-response/YearImageResponse.tsx
Normal file
45
src/image-response/YearImageResponse.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Photo } from '@/photo';
|
||||||
|
import ImageCaption from './components/ImageCaption';
|
||||||
|
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||||
|
import ImageContainer from './components/ImageContainer';
|
||||||
|
import { NextImageSize } from '@/platforms/next-image';
|
||||||
|
import IconYear from '@/components/icons/IconYear';
|
||||||
|
|
||||||
|
export default function YearImageResponse({
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
}: {
|
||||||
|
year: string
|
||||||
|
photos: Photo[]
|
||||||
|
width: NextImageSize
|
||||||
|
height: number
|
||||||
|
fontFamily: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ImageContainer solidBackground={photos.length === 0}>
|
||||||
|
<ImagePhotoGrid
|
||||||
|
{...{
|
||||||
|
photos,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ImageCaption {...{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
icon: <IconYear
|
||||||
|
size={height * .0725}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${height * .001}px)`,
|
||||||
|
marginRight: height * .01,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
title: year,
|
||||||
|
}} />
|
||||||
|
</ImageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ export default async function LensHeader({
|
|||||||
}) {
|
}) {
|
||||||
const lens = lensFromPhoto(photos[0], lensProp);
|
const lens = lensFromPhoto(photos[0], lensProp);
|
||||||
const appText = await getAppText();
|
const appText = await getAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
lens={lens}
|
lens={lens}
|
||||||
|
|||||||
@ -15,11 +15,15 @@ import RecipeHeader from '@/recipe/RecipeHeader';
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import LensHeader from '@/lens/LensHeader';
|
import LensHeader from '@/lens/LensHeader';
|
||||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||||
|
import YearHeader from '@/years/YearHeader';
|
||||||
|
import RecentsHeader from '@/recents/RecentsHeader';
|
||||||
|
|
||||||
export default function PhotoDetailPage({
|
export default function PhotoDetailPage({
|
||||||
photo,
|
photo,
|
||||||
photos,
|
photos,
|
||||||
photosGrid,
|
photosGrid,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
tag,
|
tag,
|
||||||
camera,
|
camera,
|
||||||
lens,
|
lens,
|
||||||
@ -60,6 +64,23 @@ export default function PhotoDetailPage({
|
|||||||
count={count}
|
count={count}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
/>;
|
/>;
|
||||||
|
} else if (year) {
|
||||||
|
customHeader = <YearHeader
|
||||||
|
year={year}
|
||||||
|
photos={photos}
|
||||||
|
selectedPhoto={photo}
|
||||||
|
indexNumber={indexNumber}
|
||||||
|
count={count}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>;
|
||||||
|
} else if (recent) {
|
||||||
|
customHeader = <RecentsHeader
|
||||||
|
photos={photos}
|
||||||
|
selectedPhoto={photo}
|
||||||
|
indexNumber={indexNumber}
|
||||||
|
count={count}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>;
|
||||||
} else if (camera) {
|
} else if (camera) {
|
||||||
customHeader = <CameraHeader
|
customHeader = <CameraHeader
|
||||||
camera={camera}
|
camera={camera}
|
||||||
@ -127,6 +148,8 @@ export default function PhotoDetailPage({
|
|||||||
primaryTag={tag}
|
primaryTag={tag}
|
||||||
priority
|
priority
|
||||||
prefetchRelatedLinks
|
prefetchRelatedLinks
|
||||||
|
recent={recent}
|
||||||
|
year={year}
|
||||||
showTitle={Boolean(customHeader)}
|
showTitle={Boolean(customHeader)}
|
||||||
showTitleAsH1
|
showTitleAsH1
|
||||||
showCamera={!camera}
|
showCamera={!camera}
|
||||||
@ -134,6 +157,8 @@ export default function PhotoDetailPage({
|
|||||||
showFilm={!film}
|
showFilm={!film}
|
||||||
showRecipe={!recipe}
|
showRecipe={!recipe}
|
||||||
shouldShare={shouldShare}
|
shouldShare={shouldShare}
|
||||||
|
shouldShareRecents={recent !== undefined}
|
||||||
|
shouldShareYear={year !== undefined}
|
||||||
shouldShareCamera={camera !== undefined}
|
shouldShareCamera={camera !== undefined}
|
||||||
shouldShareLens={lens !== undefined}
|
shouldShareLens={lens !== undefined}
|
||||||
shouldShareTag={tag !== undefined}
|
shouldShareTag={tag !== undefined}
|
||||||
@ -154,6 +179,7 @@ export default function PhotoDetailPage({
|
|||||||
camera={camera}
|
camera={camera}
|
||||||
film={film}
|
film={film}
|
||||||
focal={focal}
|
focal={focal}
|
||||||
|
year={year}
|
||||||
animateOnFirstLoadOnly
|
animateOnFirstLoadOnly
|
||||||
/>}
|
/>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useAppState } from '@/state/AppState';
|
|||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import useElementHeight from '@/utility/useElementHeight';
|
import useElementHeight from '@/utility/useElementHeight';
|
||||||
import MaskedScroll from '@/components/MaskedScroll';
|
import MaskedScroll from '@/components/MaskedScroll';
|
||||||
|
import { IS_RECENTS_FIRST } from '@/app/config';
|
||||||
|
|
||||||
export default function PhotoGridPageClient({
|
export default function PhotoGridPageClient({
|
||||||
photos,
|
photos,
|
||||||
@ -36,12 +37,16 @@ export default function PhotoGridPageClient({
|
|||||||
count={photosCount}
|
count={photosCount}
|
||||||
sidebar={
|
sidebar={
|
||||||
<MaskedScroll
|
<MaskedScroll
|
||||||
|
ref={ref}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'sticky top-0 -mb-5 -mt-5',
|
'sticky top-0',
|
||||||
|
// Optical adjustment for headerless recents
|
||||||
|
IS_RECENTS_FIRST ? '-mb-4.5 -mt-4.5' : '-mb-5 -mt-5',
|
||||||
'max-h-screen py-4',
|
'max-h-screen py-4',
|
||||||
)}
|
)}
|
||||||
fadeSize={100}
|
fadeSize={100}
|
||||||
setMaxSize={false}
|
setMaxSize={false}
|
||||||
|
updateMaskAfterDelay={500}
|
||||||
>
|
>
|
||||||
<PhotoGridSidebar {...{
|
<PhotoGridSidebar {...{
|
||||||
...categories,
|
...categories,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import PhotoCamera from '@/camera/PhotoCamera';
|
import PhotoCamera from '@/camera/PhotoCamera';
|
||||||
import HeaderList from '@/components/HeaderList';
|
import HeaderList from '@/components/HeaderList';
|
||||||
import PhotoTag from '@/tag/PhotoTag';
|
import PhotoTag from '@/tag/PhotoTag';
|
||||||
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
|
import { photoQuantityText } from '.';
|
||||||
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
|
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
|
||||||
import PhotoFilm from '@/film/PhotoFilm';
|
import PhotoFilm from '@/film/PhotoFilm';
|
||||||
import FavsTag from '../tag/FavsTag';
|
import FavsTag from '../tag/FavsTag';
|
||||||
@ -27,20 +27,22 @@ import {
|
|||||||
import PhotoFocalLength from '@/focal/PhotoFocalLength';
|
import PhotoFocalLength from '@/focal/PhotoFocalLength';
|
||||||
import useElementHeight from '@/utility/useElementHeight';
|
import useElementHeight from '@/utility/useElementHeight';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import IconYear from '@/components/icons/IconYear';
|
||||||
|
import PhotoYear from '@/years/PhotoYear';
|
||||||
|
import { chunkArray } from '@/utility/array';
|
||||||
|
import PhotoRecents from '@/recents/PhotoRecents';
|
||||||
|
|
||||||
const APPROXIMATE_ITEM_HEIGHT = 34;
|
const APPROXIMATE_ITEM_HEIGHT = 36;
|
||||||
const ABOUT_HEIGHT_OFFSET = 80;
|
const ABOUT_HEIGHT_OFFSET = 80;
|
||||||
|
|
||||||
export default function PhotoGridSidebar({
|
export default function PhotoGridSidebar({
|
||||||
photosCount,
|
photosCount,
|
||||||
photosDateRange,
|
|
||||||
containerHeight,
|
containerHeight,
|
||||||
aboutTextSafelyParsedHtml,
|
aboutTextSafelyParsedHtml,
|
||||||
aboutTextHasBrParagraphBreaks,
|
aboutTextHasBrParagraphBreaks,
|
||||||
..._categories
|
..._categories
|
||||||
}: PhotoSetCategories & {
|
}: PhotoSetCategories & {
|
||||||
photosCount: number
|
photosCount: number
|
||||||
photosDateRange?: PhotoDateRange
|
|
||||||
containerHeight?: number
|
containerHeight?: number
|
||||||
aboutTextSafelyParsedHtml?: string
|
aboutTextSafelyParsedHtml?: string
|
||||||
aboutTextHasBrParagraphBreaks?: boolean
|
aboutTextHasBrParagraphBreaks?: boolean
|
||||||
@ -54,6 +56,8 @@ export default function PhotoGridSidebar({
|
|||||||
, [_categories]);
|
, [_categories]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
recents,
|
||||||
|
years,
|
||||||
cameras,
|
cameras,
|
||||||
lenses,
|
lenses,
|
||||||
tags,
|
tags,
|
||||||
@ -62,6 +66,8 @@ export default function PhotoGridSidebar({
|
|||||||
focalLengths,
|
focalLengths,
|
||||||
} = categories;
|
} = categories;
|
||||||
|
|
||||||
|
const yearRows = useMemo(() => chunkArray(years, 3), [years]);
|
||||||
|
|
||||||
const categoriesCount = getCategoriesWithItemsCount(
|
const categoriesCount = getCategoriesWithItemsCount(
|
||||||
CATEGORY_VISIBILITY,
|
CATEGORY_VISIBILITY,
|
||||||
categories,
|
categories,
|
||||||
@ -83,17 +89,52 @@ export default function PhotoGridSidebar({
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const { start, end } = dateRangeForPhotos(
|
|
||||||
undefined,
|
|
||||||
photosDateRange,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { photosCountHidden } = useAppState();
|
const { photosCountHidden } = useAppState();
|
||||||
|
|
||||||
const tagsIncludingHidden = useMemo(() =>
|
const tagsIncludingHidden = useMemo(() =>
|
||||||
addHiddenToTags(tags, photosCountHidden)
|
addHiddenToTags(tags, photosCountHidden)
|
||||||
, [tags, photosCountHidden]);
|
, [tags, photosCountHidden]);
|
||||||
|
|
||||||
|
const recentsContent = recents.length > 0
|
||||||
|
? <HeaderList
|
||||||
|
key="recents"
|
||||||
|
items={[<PhotoRecents
|
||||||
|
key="recents"
|
||||||
|
countOnHover={recents[0]?.count}
|
||||||
|
type="text-only"
|
||||||
|
prefetch={false}
|
||||||
|
contrast="low"
|
||||||
|
badged
|
||||||
|
/>]}
|
||||||
|
/>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const yearsContent = years.length > 0
|
||||||
|
? <HeaderList
|
||||||
|
key="years"
|
||||||
|
title="Years"
|
||||||
|
icon={<IconYear
|
||||||
|
size={14}
|
||||||
|
className="translate-x-[0.5px]"
|
||||||
|
/>}
|
||||||
|
maxItems={maxItemsPerCategory}
|
||||||
|
items={yearRows.map((row, index) =>
|
||||||
|
<div key={index} className="flex gap-1">
|
||||||
|
{row.map(({ year, count }) =>
|
||||||
|
<PhotoYear
|
||||||
|
key={year}
|
||||||
|
year={year}
|
||||||
|
countOnHover={count}
|
||||||
|
type="text-only"
|
||||||
|
prefetch={false}
|
||||||
|
contrast="low"
|
||||||
|
suppressSpinner
|
||||||
|
badged
|
||||||
|
/>)}
|
||||||
|
</div>)}
|
||||||
|
/>
|
||||||
|
: null;
|
||||||
|
|
||||||
const camerasContent = cameras.length > 0
|
const camerasContent = cameras.length > 0
|
||||||
? <HeaderList
|
? <HeaderList
|
||||||
key="cameras"
|
key="cameras"
|
||||||
@ -243,18 +284,10 @@ export default function PhotoGridSidebar({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const photoStatsContent = photosCount > 0
|
const photoStatsContent = photosCount > 0
|
||||||
? start
|
? <HeaderList
|
||||||
? <HeaderList
|
key="photo-stats"
|
||||||
key="photo-stats"
|
items={[photoQuantityText(photosCount, appText, false)]}
|
||||||
title={photoQuantityText(photosCount, appText, false)}
|
/>
|
||||||
items={start === end
|
|
||||||
? [start]
|
|
||||||
: [`${end} –`, start]}
|
|
||||||
/>
|
|
||||||
: <HeaderList
|
|
||||||
key="photo-stats"
|
|
||||||
items={[photoQuantityText(photosCount, appText, false)]}
|
|
||||||
/>
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -274,6 +307,8 @@ export default function PhotoGridSidebar({
|
|||||||
/>}
|
/>}
|
||||||
{CATEGORY_VISIBILITY.map(category => {
|
{CATEGORY_VISIBILITY.map(category => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
|
case 'recents': return recentsContent;
|
||||||
|
case 'years': return yearsContent;
|
||||||
case 'cameras': return camerasContent;
|
case 'cameras': return camerasContent;
|
||||||
case 'lenses': return lensesContent;
|
case 'lenses': return lensesContent;
|
||||||
case 'tags': return tagsContent;
|
case 'tags': return tagsContent;
|
||||||
|
|||||||
@ -59,6 +59,8 @@ export default function PhotoLarge({
|
|||||||
priority,
|
priority,
|
||||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||||
prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS,
|
prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
revalidatePhoto,
|
revalidatePhoto,
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
showTitleAsH1,
|
showTitleAsH1,
|
||||||
@ -69,6 +71,8 @@ export default function PhotoLarge({
|
|||||||
showZoomControls: _showZoomControls = true,
|
showZoomControls: _showZoomControls = true,
|
||||||
shouldZoomOnFKeydown = true,
|
shouldZoomOnFKeydown = true,
|
||||||
shouldShare = true,
|
shouldShare = true,
|
||||||
|
shouldShareRecents,
|
||||||
|
shouldShareYear,
|
||||||
shouldShareCamera,
|
shouldShareCamera,
|
||||||
shouldShareLens,
|
shouldShareLens,
|
||||||
shouldShareTag,
|
shouldShareTag,
|
||||||
@ -85,6 +89,8 @@ export default function PhotoLarge({
|
|||||||
priority?: boolean
|
priority?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
prefetchRelatedLinks?: boolean
|
prefetchRelatedLinks?: boolean
|
||||||
|
recent?: boolean
|
||||||
|
year?: string
|
||||||
revalidatePhoto?: RevalidatePhoto
|
revalidatePhoto?: RevalidatePhoto
|
||||||
showTitle?: boolean
|
showTitle?: boolean
|
||||||
showTitleAsH1?: boolean
|
showTitleAsH1?: boolean
|
||||||
@ -95,6 +101,8 @@ export default function PhotoLarge({
|
|||||||
showZoomControls?: boolean
|
showZoomControls?: boolean
|
||||||
shouldZoomOnFKeydown?: boolean
|
shouldZoomOnFKeydown?: boolean
|
||||||
shouldShare?: boolean
|
shouldShare?: boolean
|
||||||
|
shouldShareRecents?: boolean
|
||||||
|
shouldShareYear?: boolean
|
||||||
shouldShareCamera?: boolean
|
shouldShareCamera?: boolean
|
||||||
shouldShareLens?: boolean
|
shouldShareLens?: boolean
|
||||||
shouldShareTag?: boolean
|
shouldShareTag?: boolean
|
||||||
@ -448,6 +456,12 @@ export default function PhotoLarge({
|
|||||||
<ShareButton
|
<ShareButton
|
||||||
tooltip={appText.tooltip.sharePhoto}
|
tooltip={appText.tooltip.sharePhoto}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
|
recent={shouldShareRecents
|
||||||
|
? recent
|
||||||
|
: undefined}
|
||||||
|
year={shouldShareYear
|
||||||
|
? year
|
||||||
|
: undefined}
|
||||||
tag={shouldShareTag
|
tag={shouldShareTag
|
||||||
? primaryTag
|
? primaryTag
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export default function PhotoPrevNextActions({
|
|||||||
const photoTitle = photo
|
const photoTitle = photo
|
||||||
? photo.title
|
? photo.title
|
||||||
? `'${photo.title}'`
|
? `'${photo.title}'`
|
||||||
: 'photo'
|
: appText.photo.photo.toLocaleLowerCase()
|
||||||
: undefined;
|
: undefined;
|
||||||
const downloadUrl = photo?.url;
|
const downloadUrl = photo?.url;
|
||||||
const downloadFileName = photo
|
const downloadFileName = photo
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
getUniqueFocalLengths,
|
getUniqueFocalLengths,
|
||||||
getUniqueLenses,
|
getUniqueLenses,
|
||||||
getUniqueRecipes,
|
getUniqueRecipes,
|
||||||
|
getUniqueYears,
|
||||||
} from '@/photo/db/query';
|
} from '@/photo/db/query';
|
||||||
import { GetPhotosOptions } from './db';
|
import { GetPhotosOptions } from './db';
|
||||||
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
||||||
@ -34,6 +35,7 @@ import {
|
|||||||
PREFIX_RECIPE,
|
PREFIX_RECIPE,
|
||||||
PREFIX_TAG,
|
PREFIX_TAG,
|
||||||
pathForPhoto,
|
pathForPhoto,
|
||||||
|
PREFIX_YEAR,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import { createLensKey } from '@/lens';
|
import { createLensKey } from '@/lens';
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ const KEY_TAGS = 'tags';
|
|||||||
const KEY_FILMS = 'films';
|
const KEY_FILMS = 'films';
|
||||||
const KEY_RECIPES = 'recipes';
|
const KEY_RECIPES = 'recipes';
|
||||||
const KEY_FOCAL_LENGTHS = 'focal-lengths';
|
const KEY_FOCAL_LENGTHS = 'focal-lengths';
|
||||||
|
const KEY_YEARS = 'years';
|
||||||
// Type keys
|
// Type keys
|
||||||
const KEY_COUNT = 'count';
|
const KEY_COUNT = 'count';
|
||||||
const KEY_DATE_RANGE = 'date-range';
|
const KEY_DATE_RANGE = 'date-range';
|
||||||
@ -113,6 +116,9 @@ export const revalidateFilmsKey = () =>
|
|||||||
export const revalidateFocalLengthsKey = () =>
|
export const revalidateFocalLengthsKey = () =>
|
||||||
revalidateTag(KEY_FOCAL_LENGTHS);
|
revalidateTag(KEY_FOCAL_LENGTHS);
|
||||||
|
|
||||||
|
export const revalidateYearsKey = () =>
|
||||||
|
revalidateTag(KEY_YEARS);
|
||||||
|
|
||||||
export const revalidateAllKeys = () => {
|
export const revalidateAllKeys = () => {
|
||||||
revalidatePhotosKey();
|
revalidatePhotosKey();
|
||||||
revalidateTagsKey();
|
revalidateTagsKey();
|
||||||
@ -121,6 +127,7 @@ export const revalidateAllKeys = () => {
|
|||||||
revalidateFilmsKey();
|
revalidateFilmsKey();
|
||||||
revalidateRecipesKey();
|
revalidateRecipesKey();
|
||||||
revalidateFocalLengthsKey();
|
revalidateFocalLengthsKey();
|
||||||
|
revalidateYearsKey();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const revalidateAdminPaths = () => {
|
export const revalidateAdminPaths = () => {
|
||||||
@ -141,6 +148,7 @@ export const revalidatePhoto = (photoId: string) => {
|
|||||||
revalidateFilmsKey();
|
revalidateFilmsKey();
|
||||||
revalidateRecipesKey();
|
revalidateRecipesKey();
|
||||||
revalidateFocalLengthsKey();
|
revalidateFocalLengthsKey();
|
||||||
|
revalidateYearsKey();
|
||||||
// Paths
|
// Paths
|
||||||
revalidatePath(pathForPhoto({ photo: photoId }), 'layout');
|
revalidatePath(pathForPhoto({ photo: photoId }), 'layout');
|
||||||
revalidatePath(PATH_ROOT, 'layout');
|
revalidatePath(PATH_ROOT, 'layout');
|
||||||
@ -152,6 +160,7 @@ export const revalidatePhoto = (photoId: string) => {
|
|||||||
revalidatePath(PREFIX_FILM, 'layout');
|
revalidatePath(PREFIX_FILM, 'layout');
|
||||||
revalidatePath(PREFIX_RECIPE, 'layout');
|
revalidatePath(PREFIX_RECIPE, 'layout');
|
||||||
revalidatePath(PREFIX_FOCAL_LENGTH, 'layout');
|
revalidatePath(PREFIX_FOCAL_LENGTH, 'layout');
|
||||||
|
revalidatePath(PREFIX_YEAR, 'layout');
|
||||||
revalidatePath(PATH_ADMIN, 'layout');
|
revalidatePath(PATH_ADMIN, 'layout');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -239,6 +248,12 @@ export const getUniqueFocalLengthsCached =
|
|||||||
[KEY_PHOTOS, KEY_FOCAL_LENGTHS],
|
[KEY_PHOTOS, KEY_FOCAL_LENGTHS],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getUniqueYearsCached =
|
||||||
|
unstable_cache(
|
||||||
|
getUniqueYears,
|
||||||
|
[KEY_PHOTOS, KEY_YEARS],
|
||||||
|
);
|
||||||
|
|
||||||
// No store
|
// No store
|
||||||
|
|
||||||
export const getPhotosNoStore = (...args: Parameters<typeof getPhotos>) => {
|
export const getPhotosNoStore = (...args: Parameters<typeof getPhotos>) => {
|
||||||
|
|||||||
@ -48,6 +48,8 @@ export const getWheresFromOptions = (
|
|||||||
updatedBefore,
|
updatedBefore,
|
||||||
query,
|
query,
|
||||||
maximumAspectRatio,
|
maximumAspectRatio,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
tag,
|
tag,
|
||||||
camera,
|
camera,
|
||||||
lens,
|
lens,
|
||||||
@ -90,6 +92,14 @@ export const getWheresFromOptions = (
|
|||||||
wheres.push(`aspect_ratio <= $${valuesIndex++}`);
|
wheres.push(`aspect_ratio <= $${valuesIndex++}`);
|
||||||
wheresValues.push(maximumAspectRatio);
|
wheresValues.push(maximumAspectRatio);
|
||||||
}
|
}
|
||||||
|
if (recent) {
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
wheres.push('created_at >= (SELECT MAX(created_at) - INTERVAL \'14 days\' FROM photos)');
|
||||||
|
}
|
||||||
|
if (year) {
|
||||||
|
wheres.push(`EXTRACT(YEAR FROM taken_at) = $${valuesIndex++}`);
|
||||||
|
wheresValues.push(year);
|
||||||
|
}
|
||||||
if (camera?.make) {
|
if (camera?.make) {
|
||||||
wheres.push(`${parameterizeForDb('make')}=$${valuesIndex++}`);
|
wheres.push(`${parameterizeForDb('make')}=$${valuesIndex++}`);
|
||||||
wheresValues.push(parameterize(camera.make));
|
wheresValues.push(parameterize(camera.make));
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
} from '../sync';
|
} from '../sync';
|
||||||
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||||
import { Recipes } from '@/recipe';
|
import { Recipes } from '@/recipe';
|
||||||
|
import { Years } from '@/years';
|
||||||
|
|
||||||
const createPhotosTable = () =>
|
const createPhotosTable = () =>
|
||||||
sql`
|
sql`
|
||||||
@ -321,22 +322,6 @@ export const getPhotosMostRecentUpdate = async () =>
|
|||||||
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined)
|
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined)
|
||||||
, 'getPhotosMostRecentUpdate');
|
, 'getPhotosMostRecentUpdate');
|
||||||
|
|
||||||
export const getUniqueTags = async () =>
|
|
||||||
safelyQueryPhotos(() => sql`
|
|
||||||
SELECT DISTINCT unnest(tags) as tag,
|
|
||||||
COUNT(*),
|
|
||||||
MAX(updated_at) as last_modified
|
|
||||||
FROM photos
|
|
||||||
WHERE hidden IS NOT TRUE
|
|
||||||
GROUP BY tag
|
|
||||||
ORDER BY tag ASC
|
|
||||||
`.then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
|
|
||||||
tag: tag as string,
|
|
||||||
count: parseInt(count, 10),
|
|
||||||
lastModified: last_modified as Date,
|
|
||||||
})))
|
|
||||||
, 'getUniqueTags');
|
|
||||||
|
|
||||||
export const getUniqueCameras = async () =>
|
export const getUniqueCameras = async () =>
|
||||||
safelyQueryPhotos(() => sql`
|
safelyQueryPhotos(() => sql`
|
||||||
SELECT DISTINCT make||' '||model as camera, make, model,
|
SELECT DISTINCT make||' '||model as camera, make, model,
|
||||||
@ -378,6 +363,22 @@ export const getUniqueLenses = async () =>
|
|||||||
})))
|
})))
|
||||||
, 'getUniqueLenses');
|
, 'getUniqueLenses');
|
||||||
|
|
||||||
|
export const getUniqueTags = async () =>
|
||||||
|
safelyQueryPhotos(() => sql`
|
||||||
|
SELECT DISTINCT unnest(tags) as tag,
|
||||||
|
COUNT(*),
|
||||||
|
MAX(updated_at) as last_modified
|
||||||
|
FROM photos
|
||||||
|
WHERE hidden IS NOT TRUE
|
||||||
|
GROUP BY tag
|
||||||
|
ORDER BY tag ASC
|
||||||
|
`.then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
|
||||||
|
tag,
|
||||||
|
count: parseInt(count, 10),
|
||||||
|
lastModified: last_modified as Date,
|
||||||
|
})))
|
||||||
|
, 'getUniqueTags');
|
||||||
|
|
||||||
export const getUniqueRecipes = async () =>
|
export const getUniqueRecipes = async () =>
|
||||||
safelyQueryPhotos(() => sql`
|
safelyQueryPhotos(() => sql`
|
||||||
SELECT DISTINCT recipe_title,
|
SELECT DISTINCT recipe_title,
|
||||||
@ -395,6 +396,22 @@ export const getUniqueRecipes = async () =>
|
|||||||
})))
|
})))
|
||||||
, 'getUniqueRecipes');
|
, 'getUniqueRecipes');
|
||||||
|
|
||||||
|
export const getUniqueYears = async () =>
|
||||||
|
safelyQueryPhotos(() => sql`
|
||||||
|
SELECT
|
||||||
|
DISTINCT EXTRACT(YEAR FROM taken_at) AS year,
|
||||||
|
COUNT(*),
|
||||||
|
MAX(updated_at) as last_modified
|
||||||
|
FROM photos
|
||||||
|
WHERE hidden IS NOT TRUE
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC
|
||||||
|
`.then(({ rows }): Years => rows.map(({ year, count, last_modified }) => ({
|
||||||
|
year,
|
||||||
|
count: parseInt(count, 10),
|
||||||
|
lastModified: last_modified as Date,
|
||||||
|
}))), 'getUniqueYears');
|
||||||
|
|
||||||
export const getRecipeTitleForData = async (
|
export const getRecipeTitleForData = async (
|
||||||
data: string | object,
|
data: string | object,
|
||||||
film: string,
|
film: string,
|
||||||
@ -558,7 +575,10 @@ export const getPhotosMeta = (options: GetPhotosOptions = {}) =>
|
|||||||
.then(({ rows }) => ({
|
.then(({ rows }) => ({
|
||||||
count: parseInt(rows[0].count, 10),
|
count: parseInt(rows[0].count, 10),
|
||||||
...rows[0]?.start && rows[0]?.end
|
...rows[0]?.start && rows[0]?.end
|
||||||
? { dateRange: rows[0] as PhotoDateRange }
|
? { dateRange: {
|
||||||
|
start: rows[0].start as string,
|
||||||
|
end: rows[0].end as string,
|
||||||
|
} as PhotoDateRange }
|
||||||
: undefined,
|
: undefined,
|
||||||
}));
|
}));
|
||||||
}, 'getPhotosMeta');
|
}, 'getPhotosMeta');
|
||||||
|
|||||||
29
src/recents/PhotoRecents.tsx
Normal file
29
src/recents/PhotoRecents.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
||||||
|
import EntityLink, { EntityLinkExternalProps } from
|
||||||
|
'@/components/primitives/EntityLink';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
import IconRecents from '@/components/icons/IconRecents';
|
||||||
|
|
||||||
|
export default function PhotoRecents({
|
||||||
|
countOnHover,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
countOnHover?: number
|
||||||
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityLink
|
||||||
|
{...props}
|
||||||
|
label={appText.category.recentPlural}
|
||||||
|
path={PREFIX_RECENTS}
|
||||||
|
tooltipImagePath={pathForRecentsImage()}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
|
icon={<IconRecents size={16} />}
|
||||||
|
iconBadge={<IconRecents size={10} solid />}
|
||||||
|
hoverEntity={countOnHover}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/recents/RecentsHeader.tsx
Normal file
44
src/recents/RecentsHeader.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import PhotoHeader from '@/photo/PhotoHeader';
|
||||||
|
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import PhotoRecents from './PhotoRecents';
|
||||||
|
|
||||||
|
export default function RecentsHeader({
|
||||||
|
photos,
|
||||||
|
selectedPhoto,
|
||||||
|
indexNumber,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}: {
|
||||||
|
photos: Photo[]
|
||||||
|
selectedPhoto?: Photo
|
||||||
|
indexNumber?: number
|
||||||
|
count?: number
|
||||||
|
dateRange?: PhotoDateRange
|
||||||
|
}) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhotoHeader
|
||||||
|
recent={true}
|
||||||
|
entity={<PhotoRecents showTooltip={false} />}
|
||||||
|
entityDescription={descriptionForPhotoSet(
|
||||||
|
photos,
|
||||||
|
appText,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
count,
|
||||||
|
)}
|
||||||
|
photos={photos}
|
||||||
|
selectedPhoto={selectedPhoto}
|
||||||
|
indexNumber={indexNumber}
|
||||||
|
count={count}
|
||||||
|
dateRange={dateRange}
|
||||||
|
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
||||||
|
includeShareButton
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/recents/RecentsOGTile.tsx
Normal file
36
src/recents/RecentsOGTile.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
||||||
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
|
import { descriptionForPhotoSet } from '@/photo';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
export default function RecentsOGTile({
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
photos: Photo[]
|
||||||
|
count?: number
|
||||||
|
dateRange?: PhotoDateRange
|
||||||
|
} & OGTilePropsCore) {
|
||||||
|
const appText = useAppText();
|
||||||
|
return (
|
||||||
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
|
title: appText.category.recentTitle,
|
||||||
|
description: descriptionForPhotoSet(
|
||||||
|
photos,
|
||||||
|
appText,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
),
|
||||||
|
path: PREFIX_RECENTS,
|
||||||
|
pathImage: pathForRecentsImage(),
|
||||||
|
}}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/recents/RecentsOverview.tsx
Normal file
30
src/recents/RecentsOverview.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import RecentsHeader from './RecentsHeader';
|
||||||
|
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||||
|
|
||||||
|
export default function RecentsOverview({
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
animateOnFirstLoadOnly,
|
||||||
|
}: {
|
||||||
|
photos: Photo[],
|
||||||
|
count: number,
|
||||||
|
dateRange?: PhotoDateRange,
|
||||||
|
animateOnFirstLoadOnly?: boolean,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PhotoGridContainer {...{
|
||||||
|
cacheKey: 'recents',
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
recent: true,
|
||||||
|
header: <RecentsHeader {...{
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}} />,
|
||||||
|
animateOnFirstLoadOnly,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/recents/RecentsShareModal.tsx
Normal file
22
src/recents/RecentsShareModal.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { absolutePathForRecents } from '@/app/paths';
|
||||||
|
import { PhotoSetAttributes } from '../category';
|
||||||
|
import ShareModal from '@/share/ShareModal';
|
||||||
|
import RecentsOGTile from './RecentsOGTile';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
export default function RecentsShareModal({
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}: PhotoSetAttributes) {
|
||||||
|
const appText = useAppText();
|
||||||
|
return (
|
||||||
|
<ShareModal
|
||||||
|
pathShare={absolutePathForRecents(true)}
|
||||||
|
navigatorTitle={appText.category.recentTitle}
|
||||||
|
socialText={appText.category.recentTitle}
|
||||||
|
>
|
||||||
|
<RecentsOGTile {...{ photos, count, dateRange }} />
|
||||||
|
</ShareModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/recents/data.ts
Normal file
14
src/recents/data.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {
|
||||||
|
getPhotosCached,
|
||||||
|
getPhotosMetaCached,
|
||||||
|
} from '@/photo/cache';
|
||||||
|
|
||||||
|
export const getPhotosRecentsDataCached = ({
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
limit?: number,
|
||||||
|
}) =>
|
||||||
|
Promise.all([
|
||||||
|
getPhotosCached({ recent: true, limit }),
|
||||||
|
getPhotosMetaCached({ recent: true }),
|
||||||
|
]);
|
||||||
5
src/recents/index.ts
Normal file
5
src/recents/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { CategoryQueryMeta } from '@/category';
|
||||||
|
|
||||||
|
type RecentWithMeta = CategoryQueryMeta;
|
||||||
|
|
||||||
|
export type Recents = RecentWithMeta[];
|
||||||
31
src/recents/meta.ts
Normal file
31
src/recents/meta.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import { AppTextState } from '@/i18n/state';
|
||||||
|
import {
|
||||||
|
absolutePathForRecents,
|
||||||
|
absolutePathForRecentsImage,
|
||||||
|
} from '@/app/paths';
|
||||||
|
|
||||||
|
export const generateMetaForRecents = (
|
||||||
|
photos: Photo[],
|
||||||
|
appText: AppTextState,
|
||||||
|
count?: number,
|
||||||
|
_dateRange?: PhotoDateRange,
|
||||||
|
) => {
|
||||||
|
const title = appText.category.recentTitle;
|
||||||
|
const description = descriptionForPhotoSet(
|
||||||
|
photos,
|
||||||
|
appText,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
const url = absolutePathForRecents();
|
||||||
|
const images = absolutePathForRecentsImage();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -8,6 +8,8 @@ import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
|
|||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import RecipeShareModal from '@/recipe/RecipeShareModal';
|
import RecipeShareModal from '@/recipe/RecipeShareModal';
|
||||||
import LensShareModal from '@/lens/LensShareModal';
|
import LensShareModal from '@/lens/LensShareModal';
|
||||||
|
import YearShareModal from '@/years/YearShareModal';
|
||||||
|
import RecentsShareModal from '@/recents/RecentsShareModal';
|
||||||
|
|
||||||
export default function ShareModals() {
|
export default function ShareModals() {
|
||||||
const { shareModalProps = {} } = useAppState();
|
const { shareModalProps = {} } = useAppState();
|
||||||
@ -17,17 +19,21 @@ export default function ShareModals() {
|
|||||||
photos,
|
photos,
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
camera,
|
camera,
|
||||||
lens,
|
lens,
|
||||||
tag,
|
tag,
|
||||||
film,
|
|
||||||
recipe,
|
recipe,
|
||||||
|
film,
|
||||||
focal,
|
focal,
|
||||||
} = shareModalProps;
|
} = shareModalProps;
|
||||||
|
|
||||||
if (photo) {
|
if (photo) {
|
||||||
return <PhotoShareModal {...{
|
return <PhotoShareModal {...{
|
||||||
photo,
|
photo,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
camera,
|
camera,
|
||||||
lens,
|
lens,
|
||||||
tag,
|
tag,
|
||||||
@ -37,18 +43,22 @@ export default function ShareModals() {
|
|||||||
}} />;
|
}} />;
|
||||||
} else if (photos) {
|
} else if (photos) {
|
||||||
const attributes = {photos, count, dateRange};
|
const attributes = {photos, count, dateRange};
|
||||||
if (tag) {
|
if (recent) {
|
||||||
return <TagShareModal {...{ tag, ...attributes }} />;
|
return <RecentsShareModal {...{ ...attributes }} />;
|
||||||
|
} else if (year) {
|
||||||
|
return <YearShareModal {...{ year, ...attributes }} />;
|
||||||
} else if (camera) {
|
} else if (camera) {
|
||||||
return <CameraShareModal {...{ camera, ...attributes }} />;
|
return <CameraShareModal {...{ camera, ...attributes }} />;
|
||||||
} else if (lens) {
|
} else if (lens) {
|
||||||
return <LensShareModal {...{ lens, ...attributes }} />;
|
return <LensShareModal {...{ lens, ...attributes }} />;
|
||||||
|
} else if (tag) {
|
||||||
|
return <TagShareModal {...{ tag, ...attributes }} />;
|
||||||
} else if (film) {
|
} else if (film) {
|
||||||
return <FilmShareModal {...{ film, ...attributes }} />;
|
return <FilmShareModal {...{ film, ...attributes }} />;
|
||||||
} else if (recipe) {
|
} else if (recipe) {
|
||||||
return <RecipeShareModal {...{ recipe, ...attributes }} />;
|
return <RecipeShareModal {...{ recipe, ...attributes }} />;
|
||||||
} else if (focal !== undefined) {
|
} else if (focal !== undefined) {
|
||||||
return <FocalLengthShareModal {...{ focal, ...attributes }} />;
|
return <FocalLengthShareModal {...{ focal, ...attributes }} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
absolutePathForPhotoImage,
|
absolutePathForPhotoImage,
|
||||||
absolutePathForRecipeImage,
|
absolutePathForRecipeImage,
|
||||||
absolutePathForTagImage,
|
absolutePathForTagImage,
|
||||||
|
absolutePathForYearImage,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
|
|
||||||
export type ShareModalProps = Omit<PhotoSetAttributes, 'photos'> & {
|
export type ShareModalProps = Omit<PhotoSetAttributes, 'photos'> & {
|
||||||
@ -23,6 +24,7 @@ export const getSharePathFromShareModalProps = ({
|
|||||||
recipe,
|
recipe,
|
||||||
film,
|
film,
|
||||||
focal,
|
focal,
|
||||||
|
year,
|
||||||
}: ShareModalProps) => {
|
}: ShareModalProps) => {
|
||||||
if (photo) {
|
if (photo) {
|
||||||
return absolutePathForPhotoImage(photo);
|
return absolutePathForPhotoImage(photo);
|
||||||
@ -38,5 +40,7 @@ export const getSharePathFromShareModalProps = ({
|
|||||||
return absolutePathForFilmImage(film);
|
return absolutePathForFilmImage(film);
|
||||||
} else if (focal) {
|
} else if (focal) {
|
||||||
return absolutePathForFocalLengthImage(focal);
|
return absolutePathForFocalLengthImage(focal);
|
||||||
|
} else if (year) {
|
||||||
|
return absolutePathForYearImage(year);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/utility/array.ts
Normal file
11
src/utility/array.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
function* chunkArrayGenerator<T>(
|
||||||
|
array: T[],
|
||||||
|
chunkSize: number,
|
||||||
|
): Generator<T[], void> {
|
||||||
|
for (let i = 0; i < array.length; i += chunkSize) {
|
||||||
|
yield array.slice(i, i + chunkSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
|
||||||
|
[...chunkArrayGenerator(array, chunkSize)];
|
||||||
@ -3,16 +3,16 @@ import { useState } from 'react';
|
|||||||
import { RefObject, useEffect } from 'react';
|
import { RefObject, useEffect } from 'react';
|
||||||
|
|
||||||
export default function useElementHeight(
|
export default function useElementHeight(
|
||||||
element: RefObject<HTMLElement | null>,
|
ref: RefObject<HTMLElement | null>,
|
||||||
) {
|
) {
|
||||||
const [height, setHeight] = useState(element.current?.clientHeight);
|
const [height, setHeight] = useState(ref.current?.clientHeight);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => setHeight(element.current?.clientHeight);
|
const handleResize = () => setHeight(ref.current?.clientHeight);
|
||||||
handleResize();
|
handleResize();
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [element]);
|
}, [ref]);
|
||||||
|
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/years/PhotoYear.tsx
Normal file
33
src/years/PhotoYear.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { pathForYear, pathForYearImage } from '@/app/paths';
|
||||||
|
import EntityLink, { EntityLinkExternalProps } from
|
||||||
|
'@/components/primitives/EntityLink';
|
||||||
|
import IconYear from '@/components/icons/IconYear';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
|
export default function PhotoYear({
|
||||||
|
year,
|
||||||
|
countOnHover,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
year: string
|
||||||
|
countOnHover?: number
|
||||||
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityLink
|
||||||
|
{...props}
|
||||||
|
label={year}
|
||||||
|
path={pathForYear(year)}
|
||||||
|
tooltipImagePath={pathForYearImage(year)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
|
icon={<IconYear
|
||||||
|
size={14}
|
||||||
|
className="translate-x-[0.5px] translate-y-[-0.5px]"
|
||||||
|
/>}
|
||||||
|
hoverEntity={countOnHover}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/years/YearHeader.tsx
Normal file
50
src/years/YearHeader.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import PhotoHeader from '@/photo/PhotoHeader';
|
||||||
|
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||||
|
import PhotoYear from './PhotoYear';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
export default function YearHeader({
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
selectedPhoto,
|
||||||
|
indexNumber,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}: {
|
||||||
|
year: string
|
||||||
|
photos: Photo[]
|
||||||
|
selectedPhoto?: Photo
|
||||||
|
indexNumber?: number
|
||||||
|
count?: number
|
||||||
|
dateRange?: PhotoDateRange
|
||||||
|
}) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhotoHeader
|
||||||
|
year={year}
|
||||||
|
entity={<PhotoYear
|
||||||
|
year={year}
|
||||||
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
|
/>}
|
||||||
|
entityDescription={descriptionForPhotoSet(
|
||||||
|
photos,
|
||||||
|
appText,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
count,
|
||||||
|
)}
|
||||||
|
photos={photos}
|
||||||
|
selectedPhoto={selectedPhoto}
|
||||||
|
indexNumber={indexNumber}
|
||||||
|
count={count}
|
||||||
|
dateRange={dateRange}
|
||||||
|
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
||||||
|
includeShareButton
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/years/YearOGTile.tsx
Normal file
38
src/years/YearOGTile.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import { pathForYear, pathForYearImage } from '@/app/paths';
|
||||||
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
|
import { descriptionForPhotoSet } from '@/photo';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
export default function YearOGTile({
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
year: string
|
||||||
|
photos: Photo[]
|
||||||
|
count?: number
|
||||||
|
dateRange?: PhotoDateRange
|
||||||
|
} & OGTilePropsCore) {
|
||||||
|
const appText = useAppText();
|
||||||
|
return (
|
||||||
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
|
title: appText.category.yearTitle(year),
|
||||||
|
description: descriptionForPhotoSet(
|
||||||
|
photos,
|
||||||
|
appText,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
),
|
||||||
|
path: pathForYear(year),
|
||||||
|
pathImage: pathForYearImage(year),
|
||||||
|
}}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/years/YearOverview.tsx
Normal file
33
src/years/YearOverview.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import YearHeader from './YearHeader';
|
||||||
|
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||||
|
|
||||||
|
export default function YearOverview({
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
animateOnFirstLoadOnly,
|
||||||
|
}: {
|
||||||
|
year: string,
|
||||||
|
photos: Photo[],
|
||||||
|
count: number,
|
||||||
|
dateRange?: PhotoDateRange,
|
||||||
|
animateOnFirstLoadOnly?: boolean,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PhotoGridContainer {...{
|
||||||
|
cacheKey: `year-${year}`,
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
year,
|
||||||
|
header: <YearHeader {...{
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}} />,
|
||||||
|
animateOnFirstLoadOnly,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/years/YearShareModal.tsx
Normal file
25
src/years/YearShareModal.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { absolutePathForYear } from '@/app/paths';
|
||||||
|
import { PhotoSetAttributes } from '../category';
|
||||||
|
import ShareModal from '@/share/ShareModal';
|
||||||
|
import YearOGTile from './YearOGTile';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
export default function YearShareModal({
|
||||||
|
year,
|
||||||
|
photos,
|
||||||
|
count,
|
||||||
|
dateRange,
|
||||||
|
}: {
|
||||||
|
year: string
|
||||||
|
} & PhotoSetAttributes) {
|
||||||
|
const appText = useAppText();
|
||||||
|
return (
|
||||||
|
<ShareModal
|
||||||
|
pathShare={absolutePathForYear(year, true)}
|
||||||
|
navigatorTitle={appText.category.yearTitle(year)}
|
||||||
|
socialText={appText.category.yearShare(year)}
|
||||||
|
>
|
||||||
|
<YearOGTile {...{ year, photos, count, dateRange }} />
|
||||||
|
</ShareModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/years/data.ts
Normal file
16
src/years/data.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
getPhotosCached,
|
||||||
|
getPhotosMetaCached,
|
||||||
|
} from '@/photo/cache';
|
||||||
|
|
||||||
|
export const getPhotosYearDataCached = ({
|
||||||
|
year,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
year: string,
|
||||||
|
limit?: number,
|
||||||
|
}) =>
|
||||||
|
Promise.all([
|
||||||
|
getPhotosCached({ year, limit }),
|
||||||
|
getPhotosMetaCached({ year }),
|
||||||
|
]);
|
||||||
5
src/years/index.ts
Normal file
5
src/years/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { CategoryQueryMeta } from '@/category';
|
||||||
|
|
||||||
|
type YearWithMeta = { year: string } & CategoryQueryMeta;
|
||||||
|
|
||||||
|
export type Years = YearWithMeta[];
|
||||||
29
src/years/meta.ts
Normal file
29
src/years/meta.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||||
|
import { AppTextState } from '@/i18n/state';
|
||||||
|
import { absolutePathForYear, absolutePathForYearImage } from '@/app/paths';
|
||||||
|
|
||||||
|
export const generateMetaForYear = (
|
||||||
|
year: string,
|
||||||
|
photos: Photo[],
|
||||||
|
appText: AppTextState,
|
||||||
|
count?: number,
|
||||||
|
_dateRange?: PhotoDateRange,
|
||||||
|
) => {
|
||||||
|
const title = appText.category.yearTitle(year);
|
||||||
|
const description = descriptionForPhotoSet(
|
||||||
|
photos,
|
||||||
|
appText,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
const url = absolutePathForYear(year);
|
||||||
|
const images = absolutePathForYearImage(year);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user