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:
Sam Becker 2025-06-28 11:48:48 -05:00 committed by GitHub
parent 4698c5fe64
commit b3972a6032
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1497 additions and 105 deletions

View File

@ -26,6 +26,7 @@
"ghijklmnopqrstuv",
"GPSH",
"Hasselblad",
"headerless",
"headlessui",
"hgetall",
"Hoverable",
@ -50,6 +51,7 @@
"ratelimit",
"ratelimiter",
"Reala",
"recents",
"skippable",
"sonner",
"sslmode",

View File

@ -132,6 +132,8 @@ Application behavior can be changed by configuring the following environment var
- `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`.
- Accepted values:
- `recents`
- `years`
- `tags` (default)
- `cameras` (default)
- `lenses` (default)

View File

@ -10,7 +10,8 @@ import { redirect } from 'next/navigation';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
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(
'films',
@ -31,10 +32,7 @@ export async function generateMetadata({
const [
photos,
{ count, dateRange },
] = await getPhotosFilmDataCachedCached({
film,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
] = await getPhotosFilmDataCachedCached(film);
if (photos.length === 0) { return {}; }
@ -72,10 +70,7 @@ export default async function FilmPage({
const [
photos,
{ count, dateRange },
] = await getPhotosFilmDataCachedCached({
film,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
] = await getPhotosFilmDataCachedCached(film);
if (photos.length === 0) { redirect(PATH_ROOT); }

View 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}
/>,
}} />
);
}

View 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
View 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,
}} />
);
}

View File

@ -6,8 +6,10 @@ import {
absolutePathForFocalLength,
absolutePathForLens,
absolutePathForPhoto,
absolutePathForRecents,
absolutePathForRecipe,
absolutePathForTag,
absolutePathForYear,
} from '@/app/paths';
import { isTagFavs } from '@/tag';
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
@ -16,15 +18,17 @@ import { getPhotoIdsAndUpdatedAt } from '@/photo/db/query';
// Cache for 24 hours
export const revalidate = 86_400;
const PRIORITY_HOME = 1;
const PRIORITY_HOME_VIEW = 0.9;
const PRIORITY_HOME = 1;
const PRIORITY_HOME_VIEW = 0.9;
const PRIORITY_CATEGORY_SPECIAL = 0.8;
const PRIORITY_CATEGORY = 0.7;
const PRIORITY_PHOTO = 0.5;
const PRIORITY_CATEGORY = 0.7;
const PRIORITY_PHOTO = 0.5;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [
{
recents,
years,
cameras,
lenses,
tags,
@ -35,6 +39,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
photos,
] = await Promise.all([
getDataForCategoriesCached().catch(() => ({
recents: [],
years: [],
cameras: [],
lenses: [],
tags: [],
@ -46,6 +52,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
]);
const lastModifiedSite = [
...recents.map(({ lastModified }) => lastModified),
...years.map(({ lastModified }) => lastModified),
...cameras.map(({ lastModified }) => lastModified),
...lenses.map(({ lastModified }) => lastModified),
...tags.map(({ lastModified }) => lastModified),
@ -70,6 +78,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: PRIORITY_HOME_VIEW,
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.map(({ camera, lastModified }) => ({
url: absolutePathForCamera(camera),

View 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,
}} />
);
}

View File

@ -0,0 +1,52 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import 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
View 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,
}} />
);
}

View File

@ -244,6 +244,12 @@ export const BLUR_ENABLED =
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
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 =
CATEGORY_VISIBILITY.includes('cameras');
export const SHOW_LENSES =

View File

@ -35,6 +35,8 @@ export const PREFIX_TAG = '/tag';
export const PREFIX_RECIPE = '/recipe';
export const PREFIX_FILM = '/film';
export const PREFIX_FOCAL_LENGTH = '/focal';
export const PREFIX_YEAR = '/year';
export const PREFIX_RECENTS = '/recents';
// Dynamic paths
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_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
const PATH_YEAR_DYNAMIC = `${PREFIX_YEAR}/[year]`;
const PATH_RECENTS_DYNAMIC = `${PREFIX_RECENTS}/[photoId]`;
// Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
@ -98,6 +102,8 @@ export const PATHS_TO_CACHE = [
PATH_FILM_DYNAMIC,
PATH_FOCAL_LENGTH_DYNAMIC,
PATH_RECIPE_DYNAMIC,
PATH_YEAR_DYNAMIC,
PATH_RECENTS_DYNAMIC,
...PATHS_ADMIN,
];
@ -125,6 +131,8 @@ const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
export const pathForPhoto = ({
photo,
recent,
year,
camera,
lens,
tag,
@ -136,6 +144,10 @@ export const pathForPhoto = ({
if (typeof photo !== 'string' && photo.hidden) {
prefix = pathForTag(TAG_HIDDEN);
} else if (recent) {
prefix = PREFIX_RECENTS;
} else if (year) {
prefix = pathForYear(year);
} else if (camera) {
prefix = pathForCamera(camera);
} else if (lens) {
@ -173,6 +185,9 @@ export const pathForFilm = (film: string) =>
export const pathForFocalLength = (focal: number) =>
`${PREFIX_FOCAL_LENGTH}/${focal}mm`;
export const pathForYear = (year: string) =>
`${PREFIX_YEAR}/${year}`;
// Image paths
const pathForImage = (path: string) =>
`${path}/${IMAGE}`;
@ -198,6 +213,12 @@ export const pathForFilmImage = (film: string) =>
export const pathForFocalLengthImage = (focal: number) =>
pathForImage(pathForFocalLength(focal));
export const pathForYearImage = (year: string) =>
pathForImage(pathForYear(year));
export const pathForRecentsImage = () =>
pathForImage(PREFIX_RECENTS);
// Absolute paths
export const ABSOLUTE_PATH_FOR_FEED_JSON =
`${getBaseUrl()}${PATH_FEED_JSON}`;
@ -232,6 +253,12 @@ export const absolutePathForFilm = (film: string, share?: boolean) =>
export const absolutePathForFocalLength = (focal: number, share?: boolean) =>
`${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) =>
`${getBaseUrl()}${pathForPhotoImage(photo)}`;
@ -253,10 +280,32 @@ export const absolutePathForFilmImage = (film: string) =>
export const absolutePathForFocalLengthImage = (focal: number) =>
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`;
export const absolutePathForYearImage = (year: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForYearImage(year)}`;
export const absolutePathForRecentsImage = (share?: boolean) =>
`${getBaseUrl(share)}${pathForRecentsImage()}`;
// p/[photoId]
export const isPathPhoto = (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]
export const isPathCamera = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
@ -265,6 +314,14 @@ export const isPathCamera = (pathname = '') =>
export const isPathCameraPhoto = (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]
export const isPathTag = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);
@ -358,12 +415,19 @@ export const getPathComponents = (pathname = ''): {
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
const photoIdFromFocalLength = pathname.match(
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(
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
const film = pathname.match(
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
const focalString = pathname.match(
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
? { make: cameraMake, model: cameraModel }
@ -377,36 +441,56 @@ export const getPathComponents = (pathname = ''): {
photoIdFromTag ||
photoIdFromCamera ||
photoIdFromFilm ||
photoIdFromFocalLength
photoIdFromFocalLength ||
photoIdFromYear ||
photoIdFromRecents
),
tag,
camera,
film,
focal,
year,
recent,
};
};
export const getEscapePath = (pathname?: string) => {
const {
photoId,
tag,
recent,
year,
camera,
lens,
tag,
recipe,
film,
focal,
} = getPathComponents(pathname);
if (
(photoId && isPathPhoto(pathname)) ||
(tag && isPathTag(pathname)) ||
(recent && isPathRecents(pathname)) ||
(year && isPathYear(pathname)) ||
(camera && isPathCamera(pathname)) ||
(lens && isPathLens(pathname)) ||
(tag && isPathTag(pathname)) ||
(film && isPathFilm(pathname)) ||
(focal && isPathFocalLength(pathname))
(focal && isPathFocalLength(pathname)) ||
(recipe && isPathRecipe(pathname))
) {
return PATH_ROOT;
} else if (tag && isPathTagPhoto(pathname)) {
return pathForTag(tag);
} else if (recent && isPathRecentsPhoto(pathname)) {
return PREFIX_RECENTS;
} else if (year && isPathYearPhoto(pathname)) {
return pathForYear(year);
} else if (camera && isPathCameraPhoto(pathname)) {
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)) {
return pathForFilm(film);
} else if (focal && isPathFocalLengthPhoto(pathname)) {

View File

@ -1,10 +1,12 @@
import {
getPhotosMeta,
getUniqueCameras,
getUniqueFilms,
getUniqueFocalLengths,
getUniqueLenses,
getUniqueRecipes,
getUniqueTags,
getUniqueYears,
} from '@/photo/db/query';
import {
SHOW_FILMS,
@ -13,6 +15,8 @@ import {
SHOW_RECIPES,
SHOW_CAMERAS,
SHOW_TAGS,
SHOW_YEARS,
SHOW_RECENTS,
} from '@/app/config';
import { createLensKey } from '@/lens';
import { sortTagsByCount } from '@/tag';
@ -22,6 +26,8 @@ import { sortFocalLengths } from '@/focal';
type CategoryData = Awaited<ReturnType<typeof getDataForCategories>>;
export const NULL_CATEGORY_DATA: CategoryData = {
recents: [],
years: [],
cameras: [],
lenses: [],
tags: [],
@ -31,6 +37,18 @@ export const NULL_CATEGORY_DATA: CategoryData = {
};
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
? getUniqueCameras()
.then(sortCategoriesByCount)
@ -62,6 +80,8 @@ export const getDataForCategories = () => Promise.all([
.catch(() => [])
: undefined,
]).then(([
recents = [],
years = [],
cameras = [],
lenses = [],
tags = [],
@ -69,11 +89,20 @@ export const getDataForCategories = () => Promise.all([
films = [],
focalLengths = [],
]) => ({
cameras, lenses, tags, recipes, films, focalLengths,
recents,
years,
cameras,
lenses,
tags,
recipes,
films,
focalLengths,
}));
export const getCountsForCategories = async () => {
const {
recents,
years,
cameras,
lenses,
tags,
@ -83,6 +112,13 @@ export const getCountsForCategories = async () => {
} = await getDataForCategories();
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) => {
acc[camera.cameraKey] = camera.count;
return acc;

View File

@ -6,8 +6,12 @@ import { Lens, Lenses } from '@/lens';
import { Tags } from '@/tag';
import { FocalLengths } from '@/focal';
import { Recipes } from '@/recipe';
import { Recents } from '@/recents';
import { Years } from '@/years';
const CATEGORY_KEYS = [
'recents',
'years',
'cameras',
'lenses',
'tags',
@ -40,6 +44,8 @@ export const getHiddenDefaultCategories = (keys: CategoryKeys): CategoryKeys =>
DEFAULT_CATEGORY_KEYS.filter(key => !keys.includes(key));
export interface PhotoSetCategory {
recent?: boolean
year?: string
camera?: Camera
lens?: Lens
tag?: string
@ -55,6 +61,8 @@ export interface PhotoSetCategories {
recipes: Recipes
films: Films
focalLengths: FocalLengths
years: Years
recents: Recents
}
export interface PhotoSetAttributes {

View File

@ -30,6 +30,8 @@ import {
pathForPhoto,
pathForRecipe,
pathForTag,
pathForYear,
PREFIX_RECENTS,
} from '../app/paths';
import Modal from '../components/Modal';
import { clsx } from 'clsx/lite';
@ -42,8 +44,6 @@ import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state/AppState';
import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
import { BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAction } from '@/auth/actions';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
@ -79,6 +79,7 @@ import IconRecipe from '../components/icons/IconRecipe';
import IconFocalLength from '../components/icons/IconFocalLength';
import IconFilm from '../components/icons/IconFilm';
import IconLock from '../components/icons/IconLock';
import IconYear from '../components/icons/IconYear';
import useVisualViewportHeight from '@/utility/useVisualViewport';
import useMaskedScroll from '../components/useMaskedScroll';
import { labelForFilm } from '@/film';
@ -86,6 +87,10 @@ import IconFavs from '@/components/icons/IconFavs';
import IconHidden from '@/components/icons/IconHidden';
import { useAppText } from '@/i18n/state/client';
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_DESCRIPTION = 'For searching photos, views, and settings';
@ -123,6 +128,8 @@ const renderToggle = (
});
export default function CommandKClient({
recents,
years: _years,
cameras,
lenses,
tags: _tags,
@ -289,6 +296,20 @@ export default function CommandKClient({
}
}, [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 tagsIncludingHidden = photosCountHidden > 0
? addHiddenToTags(_tags, photosCountHidden)
@ -302,6 +323,26 @@ export default function CommandKClient({
CATEGORY_VISIBILITY
.map(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 {
heading: appText.category.cameraPlural,
accessory: <IconCamera size={14} />,
@ -388,9 +429,11 @@ export default function CommandKClient({
.filter(Boolean) as CommandKSection[]
, [
appText,
tags,
recentsStatus,
years,
cameras,
lenses,
tags,
recipes,
films,
focalLengths,
@ -481,13 +524,16 @@ export default function CommandKClient({
const sectionPages: CommandKSection = {
heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
accessory: <CgFileDocument size={14} className="translate-x-[-0.5px]" />,
items: pageItems,
};
const adminSection: CommandKSection = {
heading: 'Admin',
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
accessory: <FaRegUserCircle
size={13}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>,
items: [],
};

View File

@ -9,7 +9,7 @@ import { COLLAPSE_SIDEBAR_CATEGORIES } from '@/app/config';
export default function HeaderList({
title,
className,
className = 'space-y-1',
icon,
items,
maxItems = 5,
@ -29,10 +29,7 @@ export default function HeaderList({
return (
<AnimateItems
className={clsx(
'space-y-1',
className,
)}
className={className}
scaleOffset={0.95}
duration={0.5}
staggerDelay={0.05}

View File

@ -9,6 +9,7 @@ export default function MaskedScroll({
setMaxSize,
hideScrollbar,
updateMaskOnEvents,
updateMaskAfterDelay,
scrollToEndOnMount,
style,
children,
@ -27,6 +28,7 @@ Omit<Parameters<typeof useMaskedScroll>[0], 'ref'> &
setMaxSize,
hideScrollbar,
updateMaskOnEvents,
updateMaskAfterDelay,
scrollToEndOnMount,
});

View 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} />;
}

View 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} />;
}

View File

@ -12,6 +12,7 @@ export default function ImageWithFallback({
classNameImage = 'object-cover h-full',
blurDataURL,
blurCompatibilityLevel = 'low',
priority,
...props
}: ImageProps & {
blurCompatibilityLevel?: 'none' | 'low' | 'high'
@ -57,6 +58,7 @@ export default function ImageWithFallback({
>
<Image {...{
...props,
priority,
className: classNameImage,
onLoad,
onError,

View File

@ -18,12 +18,14 @@ export interface EntityLinkExternalProps {
showTooltip?: boolean
uppercase?: boolean
prefetch?: boolean
suppressSpinner?: boolean
className?: string
}
export default function EntityLink({
ref,
icon,
iconBadge,
label,
labelSmall,
labelComplex,
@ -43,9 +45,11 @@ export default function EntityLink({
className,
classNameIcon,
uppercase,
suppressSpinner,
debug,
}: {
icon: ReactNode
iconBadge?: ReactNode
label: string
labelSmall?: ReactNode
labelComplex?: ReactNode
@ -115,10 +119,14 @@ export default function EntityLink({
? <Badge
type="small"
contrast={contrast}
className="translate-y-[-0.5px]"
className={clsx(
'translate-y-[-0.5px]',
iconBadge && '*:flex *:items-center *:gap-1',
)}
uppercase
interactive
>
{iconBadge}
{renderLabel}
</Badge>
: <span className={clsx(
@ -160,7 +168,7 @@ export default function EntityLink({
<span className="hidden peer-hover:inline text-dim">
{hoverEntity}
</span>}
{isLoading &&
{isLoading && !suppressSpinner &&
<Spinner
className={clsx(
badged && 'translate-y-[0.5px]',

View File

@ -18,6 +18,7 @@ export default function useMaskedScroll({
hideScrollbar = true,
// Disable when calling 'updateMask' explicitly
updateMaskOnEvents = true,
updateMaskAfterDelay = 0,
scrollToEndOnMount,
}: {
ref: RefObject<HTMLDivElement | null>
@ -27,6 +28,7 @@ export default function useMaskedScroll({
animationDuration?: number
setMaxSize?: boolean
hideScrollbar?: boolean
updateMaskAfterDelay?: number
scrollToEndOnMount?: boolean
}) {
const isVertical = direction === 'vertical';
@ -50,19 +52,25 @@ export default function useMaskedScroll({
}, [containerRef, isVertical]);
useEffect(() => {
// Conditionally track events
const ref = containerRef?.current;
if (ref) {
updateMask();
if (updateMaskOnEvents) {
ref.onscroll = updateMask;
ref.onresize = updateMask;
return () => {
ref.onscroll = null;
ref.onresize = null;
};
}
if (ref && updateMaskOnEvents) {
ref.onscroll = updateMask;
ref.onresize = updateMask;
return () => {
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(() => {
const ref = containerRef?.current;

View File

@ -5,10 +5,6 @@ import locale from './date-fns-locale-alias';
export type I18N = typeof EN_US;
export type I18NDeepPartial = {
[key in keyof I18N]?: Partial<I18N[key]>;
}
/**
* TRANSLATION STEPS FOR CONTRIBUTORS:
* 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<
string,
() => Promise<I18NDeepPartial | undefined>
() => Promise<I18N | undefined>
> = {
'pt-br': () => import('./locales/pt-br').then(m => m.TEXT),
'pt-pt': () => import('./locales/pt-pt').then(m => m.TEXT),

View File

@ -1,6 +1,8 @@
import { I18N } from '..';
export { bn as default } from 'date-fns/locale/bn';
export const TEXT = {
export const TEXT: I18N = {
photo: {
photo: 'ছবি',
photoPlural: 'ছবিগুলো',
@ -31,6 +33,14 @@ export const TEXT = {
focalLengthPlural: 'ফোকাল দৈর্ঘ্যগুলো',
focalLengthTitle: '{{focal}} ফোকাল দৈর্ঘ্য',
focalLengthShare: '{{focal}} এ তোলা ছবিগুলো',
year: 'বছর',
yearPlural: 'বছরসমূহ',
yearShare: '{{year}} ছবি',
yearTitle: '{{year}} সালে তোলা ছবি',
recent: 'সাম্প্রতিক',
recentPlural: 'সাম্প্রতিক',
recentTitle: 'সাম্প্রতিক ছবি',
recentSubhead: '{{distance}} আগে আপলোড হয়েছে',
},
nav: {
home: 'হোম',

View File

@ -31,6 +31,14 @@ export const TEXT = {
focalLengthPlural: 'Focal Lengths',
focalLengthTitle: 'Focal Length {{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: {
home: 'Home',

View File

@ -1,7 +1,7 @@
import { I18NDeepPartial } from '..';
import { I18N } from '..';
export { id as default } from 'date-fns/locale/id';
export const TEXT: I18NDeepPartial = {
export const TEXT: I18N = {
photo: {
photo: 'Foto',
photoPlural: 'Foto',
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
focalLengthPlural: 'Panjang Fokus',
focalLengthTitle: 'Panjang Fokus {{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: {
home: 'Beranda',

View File

@ -1,7 +1,7 @@
import { I18NDeepPartial } from '..';
import { I18N } from '..';
export { ptBR as default } from 'date-fns/locale/pt-BR';
export const TEXT: I18NDeepPartial = {
export const TEXT: I18N = {
photo: {
photo: 'Foto',
photoPlural: 'Fotos',
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
focalLengthPlural: 'Distâncias focais',
focalLengthTitle: 'Distância focal {{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: {
home: 'Início',

View File

@ -1,7 +1,7 @@
import { I18NDeepPartial } from '..';
import { I18N } from '..';
export { pt as default } from 'date-fns/locale/pt';
export const TEXT: I18NDeepPartial = {
export const TEXT: I18N = {
photo: {
photo: 'Fotografia',
photoPlural: 'Fotografias',
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
focalLengthPlural: 'Distâncias focais',
focalLengthTitle: 'Distância focal {{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: {
home: 'Início',

View File

@ -1,7 +1,7 @@
import { I18NDeepPartial } from '..';
import { I18N } from '..';
export { zhCN as default } from 'date-fns/locale/zh-CN';
export const TEXT: I18NDeepPartial = {
export const TEXT: I18N = {
photo: {
photo: '照片',
photoPlural: '照片',
@ -32,6 +32,14 @@ export const TEXT: I18NDeepPartial = {
focalLengthPlural: '焦距',
focalLengthTitle: '焦距 {{focal}}',
focalLengthShare: '焦距 {{focal}} 拍摄的照片',
year: '年份',
yearPlural: '年份',
yearShare: '{{year}} 照片',
yearTitle: '{{year}} 年拍摄的照片',
recent: '最近',
recentPlural: '最近',
recentTitle: '最近的照片',
recentSubhead: '{{distance}} 前上传',
},
nav: {
home: '首页',

View File

@ -7,6 +7,10 @@ export const generateAppTextState = (i18n: I18N) => {
...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) =>
i18n.category.cameraTitle.replace('{{camera}}', camera),
cameraShare: (camera: string) =>
@ -21,6 +25,8 @@ export const generateAppTextState = (i18n: I18N) => {
i18n.category.focalLengthTitle.replace('{{focal}}', focal),
focalLengthShare: (focal: string) =>
i18n.category.focalLengthShare.replace('{{focal}}', focal),
recentSubhead: (distance: string) =>
i18n.category.recentSubhead.replace('{{distance}}', distance),
},
admin: {
...i18n.admin,

View 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>
);
}

View 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>
);
}

View File

@ -23,6 +23,7 @@ export default async function LensHeader({
}) {
const lens = lensFromPhoto(photos[0], lensProp);
const appText = await getAppText();
return (
<PhotoHeader
lens={lens}

View File

@ -15,11 +15,15 @@ import RecipeHeader from '@/recipe/RecipeHeader';
import { ReactNode } from 'react';
import LensHeader from '@/lens/LensHeader';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import YearHeader from '@/years/YearHeader';
import RecentsHeader from '@/recents/RecentsHeader';
export default function PhotoDetailPage({
photo,
photos,
photosGrid,
recent,
year,
tag,
camera,
lens,
@ -60,6 +64,23 @@ export default function PhotoDetailPage({
count={count}
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) {
customHeader = <CameraHeader
camera={camera}
@ -127,6 +148,8 @@ export default function PhotoDetailPage({
primaryTag={tag}
priority
prefetchRelatedLinks
recent={recent}
year={year}
showTitle={Boolean(customHeader)}
showTitleAsH1
showCamera={!camera}
@ -134,6 +157,8 @@ export default function PhotoDetailPage({
showFilm={!film}
showRecipe={!recipe}
shouldShare={shouldShare}
shouldShareRecents={recent !== undefined}
shouldShareYear={year !== undefined}
shouldShareCamera={camera !== undefined}
shouldShareLens={lens !== undefined}
shouldShareTag={tag !== undefined}
@ -154,6 +179,7 @@ export default function PhotoDetailPage({
camera={camera}
film={film}
focal={focal}
year={year}
animateOnFirstLoadOnly
/>}
/>

View File

@ -9,6 +9,7 @@ import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import useElementHeight from '@/utility/useElementHeight';
import MaskedScroll from '@/components/MaskedScroll';
import { IS_RECENTS_FIRST } from '@/app/config';
export default function PhotoGridPageClient({
photos,
@ -36,12 +37,16 @@ export default function PhotoGridPageClient({
count={photosCount}
sidebar={
<MaskedScroll
ref={ref}
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',
)}
fadeSize={100}
setMaxSize={false}
updateMaskAfterDelay={500}
>
<PhotoGridSidebar {...{
...categories,

View File

@ -3,7 +3,7 @@
import PhotoCamera from '@/camera/PhotoCamera';
import HeaderList from '@/components/HeaderList';
import PhotoTag from '@/tag/PhotoTag';
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
import { photoQuantityText } from '.';
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
import PhotoFilm from '@/film/PhotoFilm';
import FavsTag from '../tag/FavsTag';
@ -27,20 +27,22 @@ import {
import PhotoFocalLength from '@/focal/PhotoFocalLength';
import useElementHeight from '@/utility/useElementHeight';
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;
export default function PhotoGridSidebar({
photosCount,
photosDateRange,
containerHeight,
aboutTextSafelyParsedHtml,
aboutTextHasBrParagraphBreaks,
..._categories
}: PhotoSetCategories & {
photosCount: number
photosDateRange?: PhotoDateRange
containerHeight?: number
aboutTextSafelyParsedHtml?: string
aboutTextHasBrParagraphBreaks?: boolean
@ -54,6 +56,8 @@ export default function PhotoGridSidebar({
, [_categories]);
const {
recents,
years,
cameras,
lenses,
tags,
@ -62,6 +66,8 @@ export default function PhotoGridSidebar({
focalLengths,
} = categories;
const yearRows = useMemo(() => chunkArray(years, 3), [years]);
const categoriesCount = getCategoriesWithItemsCount(
CATEGORY_VISIBILITY,
categories,
@ -83,17 +89,52 @@ export default function PhotoGridSidebar({
)
: undefined;
const { start, end } = dateRangeForPhotos(
undefined,
photosDateRange,
);
const { photosCountHidden } = useAppState();
const tagsIncludingHidden = useMemo(() =>
addHiddenToTags(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
? <HeaderList
key="cameras"
@ -243,18 +284,10 @@ export default function PhotoGridSidebar({
: null;
const photoStatsContent = photosCount > 0
? start
? <HeaderList
key="photo-stats"
title={photoQuantityText(photosCount, appText, false)}
items={start === end
? [start]
: [`${end} `, start]}
/>
: <HeaderList
key="photo-stats"
items={[photoQuantityText(photosCount, appText, false)]}
/>
? <HeaderList
key="photo-stats"
items={[photoQuantityText(photosCount, appText, false)]}
/>
: null;
return (
@ -274,6 +307,8 @@ export default function PhotoGridSidebar({
/>}
{CATEGORY_VISIBILITY.map(category => {
switch (category) {
case 'recents': return recentsContent;
case 'years': return yearsContent;
case 'cameras': return camerasContent;
case 'lenses': return lensesContent;
case 'tags': return tagsContent;

View File

@ -59,6 +59,8 @@ export default function PhotoLarge({
priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS,
recent,
year,
revalidatePhoto,
showTitle = true,
showTitleAsH1,
@ -69,6 +71,8 @@ export default function PhotoLarge({
showZoomControls: _showZoomControls = true,
shouldZoomOnFKeydown = true,
shouldShare = true,
shouldShareRecents,
shouldShareYear,
shouldShareCamera,
shouldShareLens,
shouldShareTag,
@ -85,6 +89,8 @@ export default function PhotoLarge({
priority?: boolean
prefetch?: boolean
prefetchRelatedLinks?: boolean
recent?: boolean
year?: string
revalidatePhoto?: RevalidatePhoto
showTitle?: boolean
showTitleAsH1?: boolean
@ -95,6 +101,8 @@ export default function PhotoLarge({
showZoomControls?: boolean
shouldZoomOnFKeydown?: boolean
shouldShare?: boolean
shouldShareRecents?: boolean
shouldShareYear?: boolean
shouldShareCamera?: boolean
shouldShareLens?: boolean
shouldShareTag?: boolean
@ -448,6 +456,12 @@ export default function PhotoLarge({
<ShareButton
tooltip={appText.tooltip.sharePhoto}
photo={photo}
recent={shouldShareRecents
? recent
: undefined}
year={shouldShareYear
? year
: undefined}
tag={shouldShareTag
? primaryTag
: undefined}

View File

@ -56,7 +56,7 @@ export default function PhotoPrevNextActions({
const photoTitle = photo
? photo.title
? `'${photo.title}'`
: 'photo'
: appText.photo.photo.toLocaleLowerCase()
: undefined;
const downloadUrl = photo?.url;
const downloadFileName = photo

View File

@ -16,6 +16,7 @@ import {
getUniqueFocalLengths,
getUniqueLenses,
getUniqueRecipes,
getUniqueYears,
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
@ -34,6 +35,7 @@ import {
PREFIX_RECIPE,
PREFIX_TAG,
pathForPhoto,
PREFIX_YEAR,
} from '@/app/paths';
import { createLensKey } from '@/lens';
@ -47,6 +49,7 @@ const KEY_TAGS = 'tags';
const KEY_FILMS = 'films';
const KEY_RECIPES = 'recipes';
const KEY_FOCAL_LENGTHS = 'focal-lengths';
const KEY_YEARS = 'years';
// Type keys
const KEY_COUNT = 'count';
const KEY_DATE_RANGE = 'date-range';
@ -113,6 +116,9 @@ export const revalidateFilmsKey = () =>
export const revalidateFocalLengthsKey = () =>
revalidateTag(KEY_FOCAL_LENGTHS);
export const revalidateYearsKey = () =>
revalidateTag(KEY_YEARS);
export const revalidateAllKeys = () => {
revalidatePhotosKey();
revalidateTagsKey();
@ -121,6 +127,7 @@ export const revalidateAllKeys = () => {
revalidateFilmsKey();
revalidateRecipesKey();
revalidateFocalLengthsKey();
revalidateYearsKey();
};
export const revalidateAdminPaths = () => {
@ -141,6 +148,7 @@ export const revalidatePhoto = (photoId: string) => {
revalidateFilmsKey();
revalidateRecipesKey();
revalidateFocalLengthsKey();
revalidateYearsKey();
// Paths
revalidatePath(pathForPhoto({ photo: photoId }), 'layout');
revalidatePath(PATH_ROOT, 'layout');
@ -152,6 +160,7 @@ export const revalidatePhoto = (photoId: string) => {
revalidatePath(PREFIX_FILM, 'layout');
revalidatePath(PREFIX_RECIPE, 'layout');
revalidatePath(PREFIX_FOCAL_LENGTH, 'layout');
revalidatePath(PREFIX_YEAR, 'layout');
revalidatePath(PATH_ADMIN, 'layout');
};
@ -239,6 +248,12 @@ export const getUniqueFocalLengthsCached =
[KEY_PHOTOS, KEY_FOCAL_LENGTHS],
);
export const getUniqueYearsCached =
unstable_cache(
getUniqueYears,
[KEY_PHOTOS, KEY_YEARS],
);
// No store
export const getPhotosNoStore = (...args: Parameters<typeof getPhotos>) => {

View File

@ -48,6 +48,8 @@ export const getWheresFromOptions = (
updatedBefore,
query,
maximumAspectRatio,
recent,
year,
tag,
camera,
lens,
@ -90,6 +92,14 @@ export const getWheresFromOptions = (
wheres.push(`aspect_ratio <= $${valuesIndex++}`);
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) {
wheres.push(`${parameterizeForDb('make')}=$${valuesIndex++}`);
wheresValues.push(parameterize(camera.make));

View File

@ -36,6 +36,7 @@ import {
} from '../sync';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Recipes } from '@/recipe';
import { Years } from '@/years';
const createPhotosTable = () =>
sql`
@ -321,22 +322,6 @@ export const getPhotosMostRecentUpdate = async () =>
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined)
, '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 () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT make||' '||model as camera, make, model,
@ -378,6 +363,22 @@ export const getUniqueLenses = async () =>
})))
, '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 () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT recipe_title,
@ -395,6 +396,22 @@ export const getUniqueRecipes = async () =>
})))
, '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 (
data: string | object,
film: string,
@ -558,7 +575,10 @@ export const getPhotosMeta = (options: GetPhotosOptions = {}) =>
.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...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,
}));
}, 'getPhotosMeta');

View 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}
/>
);
}

View 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
/>
);
}

View 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(),
}}/>
);
}

View 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,
}} />
);
}

View 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
View 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
View File

@ -0,0 +1,5 @@
import { CategoryQueryMeta } from '@/category';
type RecentWithMeta = CategoryQueryMeta;
export type Recents = RecentWithMeta[];

31
src/recents/meta.ts Normal file
View 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,
};
};

View File

@ -8,6 +8,8 @@ import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
import { useAppState } from '@/state/AppState';
import RecipeShareModal from '@/recipe/RecipeShareModal';
import LensShareModal from '@/lens/LensShareModal';
import YearShareModal from '@/years/YearShareModal';
import RecentsShareModal from '@/recents/RecentsShareModal';
export default function ShareModals() {
const { shareModalProps = {} } = useAppState();
@ -17,17 +19,21 @@ export default function ShareModals() {
photos,
count,
dateRange,
recent,
year,
camera,
lens,
tag,
film,
recipe,
film,
focal,
} = shareModalProps;
if (photo) {
return <PhotoShareModal {...{
photo,
recent,
year,
camera,
lens,
tag,
@ -37,18 +43,22 @@ export default function ShareModals() {
}} />;
} else if (photos) {
const attributes = {photos, count, dateRange};
if (tag) {
return <TagShareModal {...{ tag, ...attributes }} />;
if (recent) {
return <RecentsShareModal {...{ ...attributes }} />;
} else if (year) {
return <YearShareModal {...{ year, ...attributes }} />;
} else if (camera) {
return <CameraShareModal {...{ camera, ...attributes }} />;
} else if (lens) {
return <LensShareModal {...{ lens, ...attributes }} />;
} else if (tag) {
return <TagShareModal {...{ tag, ...attributes }} />;
} else if (film) {
return <FilmShareModal {...{ film, ...attributes }} />;
} else if (recipe) {
return <RecipeShareModal {...{ recipe, ...attributes }} />;
} else if (focal !== undefined) {
return <FocalLengthShareModal {...{ focal, ...attributes }} />;
}
}
}
}

View File

@ -8,6 +8,7 @@ import {
absolutePathForPhotoImage,
absolutePathForRecipeImage,
absolutePathForTagImage,
absolutePathForYearImage,
} from '@/app/paths';
export type ShareModalProps = Omit<PhotoSetAttributes, 'photos'> & {
@ -23,6 +24,7 @@ export const getSharePathFromShareModalProps = ({
recipe,
film,
focal,
year,
}: ShareModalProps) => {
if (photo) {
return absolutePathForPhotoImage(photo);
@ -38,5 +40,7 @@ export const getSharePathFromShareModalProps = ({
return absolutePathForFilmImage(film);
} else if (focal) {
return absolutePathForFocalLengthImage(focal);
} else if (year) {
return absolutePathForYearImage(year);
}
};

11
src/utility/array.ts Normal file
View 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)];

View File

@ -3,16 +3,16 @@ import { useState } from 'react';
import { RefObject, useEffect } from 'react';
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(() => {
const handleResize = () => setHeight(element.current?.clientHeight);
const handleResize = () => setHeight(ref.current?.clientHeight);
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [element]);
}, [ref]);
return height;
}

33
src/years/PhotoYear.tsx Normal file
View 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
View 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
View 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),
}}/>
);
}

View 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,
}} />
);
}

View 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
View 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
View 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
View 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,
};
};