Date-based photo sets (#276)
* Add 'recents' and 'years' categories * Add recents and years visibility config * Add fundamental recent/year queries * Display initial date-based data in sidebar * Adjust recents data type * Remove date rage from sidebar footer * Reformat recents/years in sidebar * Organize years in grid * Rename date -> year * Add year-based views * Split sidebar years into rows * Add years to cmdk menu * Localize 'years' * Create /recents views * Enable recents share modals * Fix recents og image * Statically optimize /recents image * Don't statically optimize /recents page * Update i18n * Add recents to cmdk * Suppress spinner for year badges * Refactor sidebar height calculation * Add recents to sitemap
This commit is contained in:
parent
4698c5fe64
commit
b3972a6032
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -26,6 +26,7 @@
|
||||
"ghijklmnopqrstuv",
|
||||
"GPSH",
|
||||
"Hasselblad",
|
||||
"headerless",
|
||||
"headlessui",
|
||||
"hgetall",
|
||||
"Hoverable",
|
||||
@ -50,6 +51,7 @@
|
||||
"ratelimit",
|
||||
"ratelimiter",
|
||||
"Reala",
|
||||
"recents",
|
||||
"skippable",
|
||||
"sonner",
|
||||
"sslmode",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
94
app/recents/[photoId]/page.tsx
Normal file
94
app/recents/[photoId]/page.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
RELATED_GRID_PHOTOS_TO_SHOW,
|
||||
descriptionForPhoto,
|
||||
titleForPhoto,
|
||||
} from '@/photo';
|
||||
import { Metadata } from 'next/types';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
PATH_ROOT,
|
||||
absolutePathForPhoto,
|
||||
absolutePathForPhotoImage,
|
||||
} from '@/app/paths';
|
||||
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||
import {
|
||||
getPhotosMetaCached,
|
||||
getPhotosNearIdCached,
|
||||
} from '@/photo/cache';
|
||||
import { cache } from 'react';
|
||||
import RecentsHeader from '@/recents/RecentsHeader';
|
||||
|
||||
const getPhotosNearIdCachedCached = cache((photoId: string) =>
|
||||
getPhotosNearIdCached(
|
||||
photoId,
|
||||
{ recent: true, limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 },
|
||||
));
|
||||
|
||||
interface PhotoRecentsProps {
|
||||
params: Promise<{ photoId: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PhotoRecentsProps): Promise<Metadata> {
|
||||
const { photoId } = await params;
|
||||
|
||||
const { photo } = await getPhotosNearIdCachedCached(photoId);
|
||||
|
||||
if (!photo) { return {}; }
|
||||
|
||||
const title = titleForPhoto(photo);
|
||||
const description = descriptionForPhoto(photo);
|
||||
const descriptionHtml = descriptionForPhoto(photo, true);
|
||||
const images = absolutePathForPhotoImage(photo);
|
||||
const url = absolutePathForPhoto({ photo, recent: true });
|
||||
|
||||
return {
|
||||
title,
|
||||
description: descriptionHtml,
|
||||
openGraph: {
|
||||
title,
|
||||
images,
|
||||
description,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoRecentsPage({
|
||||
params,
|
||||
}: PhotoRecentsProps) {
|
||||
const { photoId } = await params;
|
||||
|
||||
const { photo, photos, photosGrid, indexNumber } =
|
||||
await getPhotosNearIdCachedCached(photoId);
|
||||
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const { count, dateRange } = await getPhotosMetaCached({ recent: true });
|
||||
|
||||
return (
|
||||
<PhotoDetailPage {...{
|
||||
photo,
|
||||
photos,
|
||||
photosGrid,
|
||||
recent: true,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
header: <RecentsHeader
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
45
app/recents/image/route.tsx
Normal file
45
app/recents/image/route.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import RecentsImageResponse from
|
||||
'@/image-response/RecentsImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET() {
|
||||
const [
|
||||
photos,
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
recent: true,
|
||||
}),
|
||||
getIBMPlexMono(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
const appText = await getAppText();
|
||||
|
||||
const title = appText.category.recentPlural.toLocaleUpperCase();
|
||||
|
||||
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
|
||||
|
||||
return new ImageResponse(
|
||||
<RecentsImageResponse {...{
|
||||
title,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}}/>,
|
||||
{ width, height, fonts, headers },
|
||||
);
|
||||
}
|
||||
63
app/recents/page.tsx
Normal file
63
app/recents/page.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { generateMetaForRecents } from '@/recents/meta';
|
||||
import RecentsOverview from '@/recents/RecentsOverview';
|
||||
import { getPhotosRecentsDataCached } from '@/recents/data';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { PATH_ROOT } from '@/app/paths';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
const getPhotosRecentsDataCachedCached = cache(() =>
|
||||
getPhotosRecentsDataCached({ limit: INFINITE_SCROLL_GRID_INITIAL }));
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
] = await getPhotosRecentsDataCachedCached();
|
||||
|
||||
if (photos.length === 0) { return {}; }
|
||||
|
||||
const appText = await getAppText();
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
} = generateMetaForRecents(photos, appText, count, dateRange);
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
images,
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RecentsPage() {
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
] = await getPhotosRecentsDataCachedCached();
|
||||
|
||||
if (photos.length === 0) { redirect(PATH_ROOT); }
|
||||
|
||||
return (
|
||||
<RecentsOverview {...{
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
@ -6,8 +6,10 @@ import {
|
||||
absolutePathForFocalLength,
|
||||
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),
|
||||
|
||||
86
app/year/[year]/[photoId]/page.tsx
Normal file
86
app/year/[year]/[photoId]/page.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
RELATED_GRID_PHOTOS_TO_SHOW,
|
||||
descriptionForPhoto,
|
||||
titleForPhoto,
|
||||
} from '@/photo';
|
||||
import { Metadata } from 'next/types';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
PATH_ROOT,
|
||||
absolutePathForPhoto,
|
||||
absolutePathForPhotoImage,
|
||||
} from '@/app/paths';
|
||||
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||
import {
|
||||
getPhotosMetaCached,
|
||||
getPhotosNearIdCached,
|
||||
} from '@/photo/cache';
|
||||
import { cache } from 'react';
|
||||
|
||||
const getPhotosNearIdCachedCached = cache((photoId: string, year: string) =>
|
||||
getPhotosNearIdCached(
|
||||
photoId,
|
||||
{ year, limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 },
|
||||
));
|
||||
|
||||
interface PhotoYearProps {
|
||||
params: Promise<{ photoId: string, year: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PhotoYearProps): Promise<Metadata> {
|
||||
const { photoId, year } = await params;
|
||||
|
||||
const { photo } = await getPhotosNearIdCachedCached(photoId, year);
|
||||
|
||||
if (!photo) { return {}; }
|
||||
|
||||
const title = titleForPhoto(photo);
|
||||
const description = descriptionForPhoto(photo);
|
||||
const descriptionHtml = descriptionForPhoto(photo, true);
|
||||
const images = absolutePathForPhotoImage(photo);
|
||||
const url = absolutePathForPhoto({ photo, year });
|
||||
|
||||
return {
|
||||
title,
|
||||
description: descriptionHtml,
|
||||
openGraph: {
|
||||
title,
|
||||
images,
|
||||
description,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoYearPage({
|
||||
params,
|
||||
}: PhotoYearProps) {
|
||||
const { photoId, year } = await params;
|
||||
|
||||
const { photo, photos, photosGrid, indexNumber } =
|
||||
await getPhotosNearIdCachedCached(photoId, year);
|
||||
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const { count, dateRange } = await getPhotosMetaCached({ year: year });
|
||||
|
||||
return (
|
||||
<PhotoDetailPage {...{
|
||||
photo,
|
||||
photos,
|
||||
photosGrid,
|
||||
year,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
52
app/year/[year]/image/route.tsx
Normal file
52
app/year/[year]/image/route.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import YearImageResponse from
|
||||
'@/image-response/YearImageResponse';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { getUniqueYears } from '@/photo/db/query';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
'years',
|
||||
'image',
|
||||
getUniqueYears,
|
||||
years => years.map(({ year }) => ({ year })),
|
||||
);
|
||||
|
||||
export async function GET(
|
||||
_: Request,
|
||||
context: { params: Promise<{ year: string }> },
|
||||
) {
|
||||
const { year } = await context.params;
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
year: year,
|
||||
}),
|
||||
getIBMPlexMono(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
|
||||
|
||||
return new ImageResponse(
|
||||
<YearImageResponse {...{
|
||||
year,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}}/>,
|
||||
{ width, height, fonts, headers },
|
||||
);
|
||||
}
|
||||
85
app/year/[year]/page.tsx
Normal file
85
app/year/[year]/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { getUniqueYears } from '@/photo/db/query';
|
||||
import { generateMetaForYear } from '@/years/meta';
|
||||
import YearOverview from '@/years/YearOverview';
|
||||
import { getPhotosYearDataCached } from '@/years/data';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { PATH_ROOT } from '@/app/paths';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
const getPhotosYearDataCachedCached = cache((year: string) =>
|
||||
getPhotosYearDataCached({ year, limit: INFINITE_SCROLL_GRID_INITIAL }));
|
||||
|
||||
export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
||||
'years',
|
||||
'page',
|
||||
getUniqueYears,
|
||||
years => years.map(({ year }) => ({ year })),
|
||||
);
|
||||
|
||||
interface YearProps {
|
||||
params: Promise<{ year: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: YearProps): Promise<Metadata> {
|
||||
const { year } = await params;
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
] = await getPhotosYearDataCachedCached(year);
|
||||
|
||||
if (photos.length === 0) { return {}; }
|
||||
|
||||
const appText = await getAppText();
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
} = generateMetaForYear(year, photos, appText, count, dateRange);
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
images,
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function YearPage({
|
||||
params,
|
||||
}: YearProps) {
|
||||
const { year } = await params;
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
] = await getPhotosYearDataCachedCached(year);
|
||||
|
||||
if (photos.length === 0) { redirect(PATH_ROOT); }
|
||||
|
||||
return (
|
||||
<YearOverview {...{
|
||||
year,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
@ -244,6 +244,12 @@ export const BLUR_ENABLED =
|
||||
|
||||
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
|
||||
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 =
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
12
src/components/icons/IconRecents.tsx
Normal file
12
src/components/icons/IconRecents.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { IconBaseProps } from 'react-icons';
|
||||
import { HiLightningBolt } from 'react-icons/hi';
|
||||
import { TbBolt } from 'react-icons/tb';
|
||||
|
||||
export default function IconRecents({
|
||||
solid,
|
||||
...props
|
||||
}: IconBaseProps & { solid?: boolean}) {
|
||||
return solid
|
||||
? <HiLightningBolt {...props} />
|
||||
: <TbBolt {...props} />;
|
||||
}
|
||||
6
src/components/icons/IconYear.tsx
Normal file
6
src/components/icons/IconYear.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { IconBaseProps } from 'react-icons';
|
||||
import { LuCalendarDays } from 'react-icons/lu';
|
||||
|
||||
export default function IconYear(props: IconBaseProps) {
|
||||
return <LuCalendarDays {...props} />;
|
||||
}
|
||||
@ -12,6 +12,7 @@ export default function ImageWithFallback({
|
||||
classNameImage = 'object-cover h-full',
|
||||
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,
|
||||
|
||||
@ -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]',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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: 'হোম',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '首页',
|
||||
|
||||
@ -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,
|
||||
|
||||
45
src/image-response/RecentsImageResponse.tsx
Normal file
45
src/image-response/RecentsImageResponse.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Photo } from '@/photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
import IconRecents from '@/components/icons/IconRecents';
|
||||
|
||||
export default function RecentsImageResponse({
|
||||
title,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}: {
|
||||
title: string
|
||||
photos: Photo[]
|
||||
width: NextImageSize
|
||||
height: number
|
||||
fontFamily: string
|
||||
}) {
|
||||
return (
|
||||
<ImageContainer solidBackground={photos.length === 0}>
|
||||
<ImagePhotoGrid
|
||||
{...{
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: <IconRecents
|
||||
size={height * .08}
|
||||
style={{
|
||||
transform: `translateY(${height * .003}px)`,
|
||||
marginRight: height * .01,
|
||||
}}
|
||||
/>,
|
||||
title,
|
||||
}} />
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
45
src/image-response/YearImageResponse.tsx
Normal file
45
src/image-response/YearImageResponse.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Photo } from '@/photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
import IconYear from '@/components/icons/IconYear';
|
||||
|
||||
export default function YearImageResponse({
|
||||
year,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}: {
|
||||
year: string
|
||||
photos: Photo[]
|
||||
width: NextImageSize
|
||||
height: number
|
||||
fontFamily: string
|
||||
}) {
|
||||
return (
|
||||
<ImageContainer solidBackground={photos.length === 0}>
|
||||
<ImagePhotoGrid
|
||||
{...{
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: <IconYear
|
||||
size={height * .0725}
|
||||
style={{
|
||||
transform: `translateY(${height * .001}px)`,
|
||||
marginRight: height * .01,
|
||||
}}
|
||||
/>,
|
||||
title: year,
|
||||
}} />
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
@ -23,6 +23,7 @@ export default async function LensHeader({
|
||||
}) {
|
||||
const lens = lensFromPhoto(photos[0], lensProp);
|
||||
const appText = await getAppText();
|
||||
|
||||
return (
|
||||
<PhotoHeader
|
||||
lens={lens}
|
||||
|
||||
@ -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
|
||||
/>}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>) => {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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');
|
||||
|
||||
29
src/recents/PhotoRecents.tsx
Normal file
29
src/recents/PhotoRecents.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
||||
import EntityLink, { EntityLinkExternalProps } from
|
||||
'@/components/primitives/EntityLink';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import IconRecents from '@/components/icons/IconRecents';
|
||||
|
||||
export default function PhotoRecents({
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={appText.category.recentPlural}
|
||||
path={PREFIX_RECENTS}
|
||||
tooltipImagePath={pathForRecentsImage()}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<IconRecents size={16} />}
|
||||
iconBadge={<IconRecents size={10} solid />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/recents/RecentsHeader.tsx
Normal file
44
src/recents/RecentsHeader.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import PhotoRecents from './PhotoRecents';
|
||||
|
||||
export default function RecentsHeader({
|
||||
photos,
|
||||
selectedPhoto,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<PhotoHeader
|
||||
recent={true}
|
||||
entity={<PhotoRecents showTooltip={false} />}
|
||||
entityDescription={descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
||||
includeShareButton
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
src/recents/RecentsOGTile.tsx
Normal file
36
src/recents/RecentsOGTile.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { descriptionForPhotoSet } from '@/photo';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function RecentsOGTile({
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
photos: Photo[]
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: appText.category.recentTitle,
|
||||
description: descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
dateRange,
|
||||
),
|
||||
path: PREFIX_RECENTS,
|
||||
pathImage: pathForRecentsImage(),
|
||||
}}/>
|
||||
);
|
||||
}
|
||||
30
src/recents/RecentsOverview.tsx
Normal file
30
src/recents/RecentsOverview.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import RecentsHeader from './RecentsHeader';
|
||||
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||
|
||||
export default function RecentsOverview({
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
animateOnFirstLoadOnly,
|
||||
}: {
|
||||
photos: Photo[],
|
||||
count: number,
|
||||
dateRange?: PhotoDateRange,
|
||||
animateOnFirstLoadOnly?: boolean,
|
||||
}) {
|
||||
return (
|
||||
<PhotoGridContainer {...{
|
||||
cacheKey: 'recents',
|
||||
photos,
|
||||
count,
|
||||
recent: true,
|
||||
header: <RecentsHeader {...{
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}} />,
|
||||
animateOnFirstLoadOnly,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
22
src/recents/RecentsShareModal.tsx
Normal file
22
src/recents/RecentsShareModal.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { absolutePathForRecents } from '@/app/paths';
|
||||
import { PhotoSetAttributes } from '../category';
|
||||
import ShareModal from '@/share/ShareModal';
|
||||
import RecentsOGTile from './RecentsOGTile';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function RecentsShareModal({
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}: PhotoSetAttributes) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<ShareModal
|
||||
pathShare={absolutePathForRecents(true)}
|
||||
navigatorTitle={appText.category.recentTitle}
|
||||
socialText={appText.category.recentTitle}
|
||||
>
|
||||
<RecentsOGTile {...{ photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
);
|
||||
}
|
||||
14
src/recents/data.ts
Normal file
14
src/recents/data.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosMetaCached,
|
||||
} from '@/photo/cache';
|
||||
|
||||
export const getPhotosRecentsDataCached = ({
|
||||
limit,
|
||||
}: {
|
||||
limit?: number,
|
||||
}) =>
|
||||
Promise.all([
|
||||
getPhotosCached({ recent: true, limit }),
|
||||
getPhotosMetaCached({ recent: true }),
|
||||
]);
|
||||
5
src/recents/index.ts
Normal file
5
src/recents/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { CategoryQueryMeta } from '@/category';
|
||||
|
||||
type RecentWithMeta = CategoryQueryMeta;
|
||||
|
||||
export type Recents = RecentWithMeta[];
|
||||
31
src/recents/meta.ts
Normal file
31
src/recents/meta.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||
import { AppTextState } from '@/i18n/state';
|
||||
import {
|
||||
absolutePathForRecents,
|
||||
absolutePathForRecentsImage,
|
||||
} from '@/app/paths';
|
||||
|
||||
export const generateMetaForRecents = (
|
||||
photos: Photo[],
|
||||
appText: AppTextState,
|
||||
count?: number,
|
||||
_dateRange?: PhotoDateRange,
|
||||
) => {
|
||||
const title = appText.category.recentTitle;
|
||||
const description = descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
);
|
||||
const url = absolutePathForRecents();
|
||||
const images = absolutePathForRecentsImage();
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
images,
|
||||
};
|
||||
};
|
||||
@ -8,6 +8,8 @@ import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import 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 }} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
11
src/utility/array.ts
Normal file
@ -0,0 +1,11 @@
|
||||
function* chunkArrayGenerator<T>(
|
||||
array: T[],
|
||||
chunkSize: number,
|
||||
): Generator<T[], void> {
|
||||
for (let i = 0; i < array.length; i += chunkSize) {
|
||||
yield array.slice(i, i + chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
export const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
|
||||
[...chunkArrayGenerator(array, chunkSize)];
|
||||
@ -3,16 +3,16 @@ import { useState } from 'react';
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
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
33
src/years/PhotoYear.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { pathForYear, pathForYearImage } from '@/app/paths';
|
||||
import EntityLink, { EntityLinkExternalProps } from
|
||||
'@/components/primitives/EntityLink';
|
||||
import IconYear from '@/components/icons/IconYear';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoYear({
|
||||
year,
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
year: string
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={year}
|
||||
path={pathForYear(year)}
|
||||
tooltipImagePath={pathForYearImage(year)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<IconYear
|
||||
size={14}
|
||||
className="translate-x-[0.5px] translate-y-[-0.5px]"
|
||||
/>}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
src/years/YearHeader.tsx
Normal file
50
src/years/YearHeader.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import PhotoYear from './PhotoYear';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function YearHeader({
|
||||
year,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
year: string
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<PhotoHeader
|
||||
year={year}
|
||||
entity={<PhotoYear
|
||||
year={year}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
/>}
|
||||
entityDescription={descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
||||
includeShareButton
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
src/years/YearOGTile.tsx
Normal file
38
src/years/YearOGTile.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { pathForYear, pathForYearImage } from '@/app/paths';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { descriptionForPhotoSet } from '@/photo';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function YearOGTile({
|
||||
year,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
year: string
|
||||
photos: Photo[]
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: appText.category.yearTitle(year),
|
||||
description: descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
dateRange,
|
||||
),
|
||||
path: pathForYear(year),
|
||||
pathImage: pathForYearImage(year),
|
||||
}}/>
|
||||
);
|
||||
}
|
||||
33
src/years/YearOverview.tsx
Normal file
33
src/years/YearOverview.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import YearHeader from './YearHeader';
|
||||
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||
|
||||
export default function YearOverview({
|
||||
year,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
animateOnFirstLoadOnly,
|
||||
}: {
|
||||
year: string,
|
||||
photos: Photo[],
|
||||
count: number,
|
||||
dateRange?: PhotoDateRange,
|
||||
animateOnFirstLoadOnly?: boolean,
|
||||
}) {
|
||||
return (
|
||||
<PhotoGridContainer {...{
|
||||
cacheKey: `year-${year}`,
|
||||
photos,
|
||||
count,
|
||||
year,
|
||||
header: <YearHeader {...{
|
||||
year,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}} />,
|
||||
animateOnFirstLoadOnly,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
25
src/years/YearShareModal.tsx
Normal file
25
src/years/YearShareModal.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { absolutePathForYear } from '@/app/paths';
|
||||
import { PhotoSetAttributes } from '../category';
|
||||
import ShareModal from '@/share/ShareModal';
|
||||
import YearOGTile from './YearOGTile';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function YearShareModal({
|
||||
year,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
year: string
|
||||
} & PhotoSetAttributes) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<ShareModal
|
||||
pathShare={absolutePathForYear(year, true)}
|
||||
navigatorTitle={appText.category.yearTitle(year)}
|
||||
socialText={appText.category.yearShare(year)}
|
||||
>
|
||||
<YearOGTile {...{ year, photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
);
|
||||
}
|
||||
16
src/years/data.ts
Normal file
16
src/years/data.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosMetaCached,
|
||||
} from '@/photo/cache';
|
||||
|
||||
export const getPhotosYearDataCached = ({
|
||||
year,
|
||||
limit,
|
||||
}: {
|
||||
year: string,
|
||||
limit?: number,
|
||||
}) =>
|
||||
Promise.all([
|
||||
getPhotosCached({ year, limit }),
|
||||
getPhotosMetaCached({ year }),
|
||||
]);
|
||||
5
src/years/index.ts
Normal file
5
src/years/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { CategoryQueryMeta } from '@/category';
|
||||
|
||||
type YearWithMeta = { year: string } & CategoryQueryMeta;
|
||||
|
||||
export type Years = YearWithMeta[];
|
||||
29
src/years/meta.ts
Normal file
29
src/years/meta.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
|
||||
import { AppTextState } from '@/i18n/state';
|
||||
import { absolutePathForYear, absolutePathForYearImage } from '@/app/paths';
|
||||
|
||||
export const generateMetaForYear = (
|
||||
year: string,
|
||||
photos: Photo[],
|
||||
appText: AppTextState,
|
||||
count?: number,
|
||||
_dateRange?: PhotoDateRange,
|
||||
) => {
|
||||
const title = appText.category.yearTitle(year);
|
||||
const description = descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
undefined,
|
||||
undefined,
|
||||
count,
|
||||
);
|
||||
const url = absolutePathForYear(year);
|
||||
const images = absolutePathForYearImage(year);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
images,
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user