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", "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",

View File

@ -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)

View File

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

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, 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),

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( 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 =

View File

@ -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)) {

View File

@ -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;

View File

@ -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 {

View File

@ -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: [],
}; };

View File

@ -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}

View File

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

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', 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,

View File

@ -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]',

View File

@ -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;

View File

@ -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),

View File

@ -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: 'হোম',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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: '首页',

View File

@ -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,

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 lens = lensFromPhoto(photos[0], lensProp);
const appText = await getAppText(); const appText = await getAppText();
return ( return (
<PhotoHeader <PhotoHeader
lens={lens} lens={lens}

View File

@ -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
/>} />}
/> />

View File

@ -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,

View File

@ -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;

View File

@ -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}

View File

@ -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

View File

@ -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>) => {

View File

@ -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));

View File

@ -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');

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

View File

@ -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
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'; 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
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,
};
};