diff --git a/.vscode/settings.json b/.vscode/settings.json index eef74eea..dddfcf15 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "ghijklmnopqrstuv", "GPSH", "Hasselblad", + "headerless", "headlessui", "hgetall", "Hoverable", @@ -50,6 +51,7 @@ "ratelimit", "ratelimiter", "Reala", + "recents", "skippable", "sonner", "sslmode", diff --git a/README.md b/README.md index 6b7f8798..82d7c3c3 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/film/[film]/page.tsx b/app/film/[film]/page.tsx index 7be7edbe..b230dcd2 100644 --- a/app/film/[film]/page.tsx +++ b/app/film/[film]/page.tsx @@ -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); } diff --git a/app/recents/[photoId]/page.tsx b/app/recents/[photoId]/page.tsx new file mode 100644 index 00000000..1fad819e --- /dev/null +++ b/app/recents/[photoId]/page.tsx @@ -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 { + 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 ( + , + }} /> + ); +} \ No newline at end of file diff --git a/app/recents/image/route.tsx b/app/recents/image/route.tsx new file mode 100644 index 00000000..4402caed --- /dev/null +++ b/app/recents/image/route.tsx @@ -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( + , + { width, height, fonts, headers }, + ); +} \ No newline at end of file diff --git a/app/recents/page.tsx b/app/recents/page.tsx new file mode 100644 index 00000000..94c35940 --- /dev/null +++ b/app/recents/page.tsx @@ -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 { + 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 ( + + ); +} \ No newline at end of file diff --git a/app/sitemap.ts b/app/sitemap.ts index 363d5bb2..117dce63 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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 { const [ { + recents, + years, cameras, lenses, tags, @@ -35,6 +39,8 @@ export default async function sitemap(): Promise { photos, ] = await Promise.all([ getDataForCategoriesCached().catch(() => ({ + recents: [], + years: [], cameras: [], lenses: [], tags: [], @@ -46,6 +52,8 @@ export default async function sitemap(): Promise { ]); 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 { 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), diff --git a/app/year/[year]/[photoId]/page.tsx b/app/year/[year]/[photoId]/page.tsx new file mode 100644 index 00000000..a01b6972 --- /dev/null +++ b/app/year/[year]/[photoId]/page.tsx @@ -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 { + 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 ( + + ); +} diff --git a/app/year/[year]/image/route.tsx b/app/year/[year]/image/route.tsx new file mode 100644 index 00000000..669425cb --- /dev/null +++ b/app/year/[year]/image/route.tsx @@ -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( + , + { width, height, fonts, headers }, + ); +} \ No newline at end of file diff --git a/app/year/[year]/page.tsx b/app/year/[year]/page.tsx new file mode 100644 index 00000000..7fd48038 --- /dev/null +++ b/app/year/[year]/page.tsx @@ -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 { + 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 ( + + ); +} \ No newline at end of file diff --git a/src/app/config.ts b/src/app/config.ts index a01875f3..6bf4507f 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -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 = diff --git a/src/app/paths.ts b/src/app/paths.ts index 4e5f5302..6eab1d4f 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -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)) { diff --git a/src/category/data.ts b/src/category/data.ts index 7a90bd21..0d68fba9 100644 --- a/src/category/data.ts +++ b/src/category/data.ts @@ -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>; 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, + years: years.reduce((acc, year) => { + acc[year.year] = year.count; + return acc; + }, {} as Record), cameras: cameras.reduce((acc, camera) => { acc[camera.cameraKey] = camera.count; return acc; diff --git a/src/category/index.ts b/src/category/index.ts index cd80986a..88663f94 100644 --- a/src/category/index.ts +++ b/src/category/index.ts @@ -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 { diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index 3b86c7d7..6adc5809 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -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: , + items: recentsStatus ? [{ + label: recentsStatus.subhead, + annotation: formatCount(recentsStatus.count), + annotationAria: formatCountDescriptive(recentsStatus.count), + path: PREFIX_RECENTS, + }] : [], + }; + case 'years': return { + heading: appText.category.yearPlural, + accessory: , + items: years.map(({ year, count }) => ({ + label: year, + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + path: pathForYear(year), + })), + }; case 'cameras': return { heading: appText.category.cameraPlural, accessory: , @@ -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: , + accessory: , items: pageItems, }; const adminSection: CommandKSection = { heading: 'Admin', - accessory: , + accessory: , items: [], }; diff --git a/src/components/HeaderList.tsx b/src/components/HeaderList.tsx index 1a60a4d4..61723dba 100644 --- a/src/components/HeaderList.tsx +++ b/src/components/HeaderList.tsx @@ -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 ( [0], 'ref'> & setMaxSize, hideScrollbar, updateMaskOnEvents, + updateMaskAfterDelay, scrollToEndOnMount, }); diff --git a/src/components/icons/IconRecents.tsx b/src/components/icons/IconRecents.tsx new file mode 100644 index 00000000..0efd380c --- /dev/null +++ b/src/components/icons/IconRecents.tsx @@ -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 + ? + : ; +} diff --git a/src/components/icons/IconYear.tsx b/src/components/icons/IconYear.tsx new file mode 100644 index 00000000..f3e91ac7 --- /dev/null +++ b/src/components/icons/IconYear.tsx @@ -0,0 +1,6 @@ +import { IconBaseProps } from 'react-icons'; +import { LuCalendarDays } from 'react-icons/lu'; + +export default function IconYear(props: IconBaseProps) { + return ; +} diff --git a/src/components/image/ImageWithFallback.tsx b/src/components/image/ImageWithFallback.tsx index b2328293..c1d62a7a 100644 --- a/src/components/image/ImageWithFallback.tsx +++ b/src/components/image/ImageWithFallback.tsx @@ -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({ > + {iconBadge} {renderLabel} : {hoverEntity} } - {isLoading && + {isLoading && !suppressSpinner && @@ -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; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index a6496c1e..8ff55222 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -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; -} - /** * 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 + () => Promise > = { 'pt-br': () => import('./locales/pt-br').then(m => m.TEXT), 'pt-pt': () => import('./locales/pt-pt').then(m => m.TEXT), diff --git a/src/i18n/locales/bd-bn.ts b/src/i18n/locales/bd-bn.ts index 5bed115a..82699dad 100644 --- a/src/i18n/locales/bd-bn.ts +++ b/src/i18n/locales/bd-bn.ts @@ -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: 'হোম', diff --git a/src/i18n/locales/en-us.ts b/src/i18n/locales/en-us.ts index d5b6b69d..d7add044 100644 --- a/src/i18n/locales/en-us.ts +++ b/src/i18n/locales/en-us.ts @@ -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', diff --git a/src/i18n/locales/id-id.ts b/src/i18n/locales/id-id.ts index c67c6fea..0eab0b42 100644 --- a/src/i18n/locales/id-id.ts +++ b/src/i18n/locales/id-id.ts @@ -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', diff --git a/src/i18n/locales/pt-br.ts b/src/i18n/locales/pt-br.ts index 8117a6fc..6cf6b0f1 100644 --- a/src/i18n/locales/pt-br.ts +++ b/src/i18n/locales/pt-br.ts @@ -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', diff --git a/src/i18n/locales/pt-pt.ts b/src/i18n/locales/pt-pt.ts index 64254654..04aaea47 100644 --- a/src/i18n/locales/pt-pt.ts +++ b/src/i18n/locales/pt-pt.ts @@ -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', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index a0d994b0..12760731 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -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: '首页', diff --git a/src/i18n/state/index.ts b/src/i18n/state/index.ts index e42886a0..2663ee7b 100644 --- a/src/i18n/state/index.ts +++ b/src/i18n/state/index.ts @@ -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, diff --git a/src/image-response/RecentsImageResponse.tsx b/src/image-response/RecentsImageResponse.tsx new file mode 100644 index 00000000..da944d5f --- /dev/null +++ b/src/image-response/RecentsImageResponse.tsx @@ -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 ( + + + , + title, + }} /> + + ); +} diff --git a/src/image-response/YearImageResponse.tsx b/src/image-response/YearImageResponse.tsx new file mode 100644 index 00000000..ccd83358 --- /dev/null +++ b/src/image-response/YearImageResponse.tsx @@ -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 ( + + + , + title: year, + }} /> + + ); +} diff --git a/src/lens/LensHeader.tsx b/src/lens/LensHeader.tsx index 01ad4a73..8ac8600e 100644 --- a/src/lens/LensHeader.tsx +++ b/src/lens/LensHeader.tsx @@ -23,6 +23,7 @@ export default async function LensHeader({ }) { const lens = lensFromPhoto(photos[0], lensProp); const appText = await getAppText(); + return ( ; + } else if (year) { + customHeader = ; + } else if (recent) { + customHeader = ; } else if (camera) { customHeader = } /> diff --git a/src/photo/PhotoGridPageClient.tsx b/src/photo/PhotoGridPageClient.tsx index 4d467410..84e0ef36 100644 --- a/src/photo/PhotoGridPageClient.tsx +++ b/src/photo/PhotoGridPageClient.tsx @@ -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={ 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 + ? ]} + /> + : null; + + const yearsContent = years.length > 0 + ? } + maxItems={maxItemsPerCategory} + items={yearRows.map((row, index) => +
+ {row.map(({ year, count }) => + )} +
)} + /> + : null; + const camerasContent = cameras.length > 0 ? 0 - ? start - ? - : + ? : 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; diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index f48273d4..ed34540c 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -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({ 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) => { diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index a73a9591..9893ffc1 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -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)); diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 4c48e661..717f028c 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -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'); diff --git a/src/recents/PhotoRecents.tsx b/src/recents/PhotoRecents.tsx new file mode 100644 index 00000000..47599e64 --- /dev/null +++ b/src/recents/PhotoRecents.tsx @@ -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 ( + } + iconBadge={} + hoverEntity={countOnHover} + /> + ); +} \ No newline at end of file diff --git a/src/recents/RecentsHeader.tsx b/src/recents/RecentsHeader.tsx new file mode 100644 index 00000000..4d507f60 --- /dev/null +++ b/src/recents/RecentsHeader.tsx @@ -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 ( + } + entityDescription={descriptionForPhotoSet( + photos, + appText, + undefined, + undefined, + count, + )} + photos={photos} + selectedPhoto={selectedPhoto} + indexNumber={indexNumber} + count={count} + dateRange={dateRange} + hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED} + includeShareButton + /> + ); +} diff --git a/src/recents/RecentsOGTile.tsx b/src/recents/RecentsOGTile.tsx new file mode 100644 index 00000000..4bb3fbdd --- /dev/null +++ b/src/recents/RecentsOGTile.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/src/recents/RecentsOverview.tsx b/src/recents/RecentsOverview.tsx new file mode 100644 index 00000000..37de70a7 --- /dev/null +++ b/src/recents/RecentsOverview.tsx @@ -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 ( + , + animateOnFirstLoadOnly, + }} /> + ); +} diff --git a/src/recents/RecentsShareModal.tsx b/src/recents/RecentsShareModal.tsx new file mode 100644 index 00000000..1e5738b7 --- /dev/null +++ b/src/recents/RecentsShareModal.tsx @@ -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 ( + + + + ); +} \ No newline at end of file diff --git a/src/recents/data.ts b/src/recents/data.ts new file mode 100644 index 00000000..ddbf71d4 --- /dev/null +++ b/src/recents/data.ts @@ -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 }), + ]); diff --git a/src/recents/index.ts b/src/recents/index.ts new file mode 100644 index 00000000..26f2a2e3 --- /dev/null +++ b/src/recents/index.ts @@ -0,0 +1,5 @@ +import { CategoryQueryMeta } from '@/category'; + +type RecentWithMeta = CategoryQueryMeta; + +export type Recents = RecentWithMeta[]; diff --git a/src/recents/meta.ts b/src/recents/meta.ts new file mode 100644 index 00000000..a1b6e6fa --- /dev/null +++ b/src/recents/meta.ts @@ -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, + }; +}; diff --git a/src/share/ShareModals.tsx b/src/share/ShareModals.tsx index 54ad1f13..4323ac32 100644 --- a/src/share/ShareModals.tsx +++ b/src/share/ShareModals.tsx @@ -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 ; } else if (photos) { const attributes = {photos, count, dateRange}; - if (tag) { - return ; + if (recent) { + return ; + } else if (year) { + return ; } else if (camera) { return ; } else if (lens) { return ; + } else if (tag) { + return ; } else if (film) { return ; } else if (recipe) { return ; } else if (focal !== undefined) { return ; - } + } } } diff --git a/src/share/index.ts b/src/share/index.ts index 701967e6..dac477fb 100644 --- a/src/share/index.ts +++ b/src/share/index.ts @@ -8,6 +8,7 @@ import { absolutePathForPhotoImage, absolutePathForRecipeImage, absolutePathForTagImage, + absolutePathForYearImage, } from '@/app/paths'; export type ShareModalProps = Omit & { @@ -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); } }; diff --git a/src/utility/array.ts b/src/utility/array.ts new file mode 100644 index 00000000..c492480f --- /dev/null +++ b/src/utility/array.ts @@ -0,0 +1,11 @@ +function* chunkArrayGenerator( + array: T[], + chunkSize: number, +): Generator { + for (let i = 0; i < array.length; i += chunkSize) { + yield array.slice(i, i + chunkSize); + } +} + +export const chunkArray = (array: T[], chunkSize: number): T[][] => + [...chunkArrayGenerator(array, chunkSize)]; diff --git a/src/utility/useElementHeight.ts b/src/utility/useElementHeight.ts index 8f0d992f..3be2f8a7 100644 --- a/src/utility/useElementHeight.ts +++ b/src/utility/useElementHeight.ts @@ -3,16 +3,16 @@ import { useState } from 'react'; import { RefObject, useEffect } from 'react'; export default function useElementHeight( - element: RefObject, + ref: RefObject, ) { - 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; } diff --git a/src/years/PhotoYear.tsx b/src/years/PhotoYear.tsx new file mode 100644 index 00000000..6aa4ef20 --- /dev/null +++ b/src/years/PhotoYear.tsx @@ -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 ( + } + hoverEntity={countOnHover} + /> + ); +} \ No newline at end of file diff --git a/src/years/YearHeader.tsx b/src/years/YearHeader.tsx new file mode 100644 index 00000000..62eeb66d --- /dev/null +++ b/src/years/YearHeader.tsx @@ -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 ( + } + entityDescription={descriptionForPhotoSet( + photos, + appText, + undefined, + undefined, + count, + )} + photos={photos} + selectedPhoto={selectedPhoto} + indexNumber={indexNumber} + count={count} + dateRange={dateRange} + hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED} + includeShareButton + /> + ); +} \ No newline at end of file diff --git a/src/years/YearOGTile.tsx b/src/years/YearOGTile.tsx new file mode 100644 index 00000000..45fff7e2 --- /dev/null +++ b/src/years/YearOGTile.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/src/years/YearOverview.tsx b/src/years/YearOverview.tsx new file mode 100644 index 00000000..5e54e645 --- /dev/null +++ b/src/years/YearOverview.tsx @@ -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 ( + , + animateOnFirstLoadOnly, + }} /> + ); +} \ No newline at end of file diff --git a/src/years/YearShareModal.tsx b/src/years/YearShareModal.tsx new file mode 100644 index 00000000..e307b0f5 --- /dev/null +++ b/src/years/YearShareModal.tsx @@ -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 ( + + + + ); +} \ No newline at end of file diff --git a/src/years/data.ts b/src/years/data.ts new file mode 100644 index 00000000..856b7bcb --- /dev/null +++ b/src/years/data.ts @@ -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 }), + ]); diff --git a/src/years/index.ts b/src/years/index.ts new file mode 100644 index 00000000..943c65db --- /dev/null +++ b/src/years/index.ts @@ -0,0 +1,5 @@ +import { CategoryQueryMeta } from '@/category'; + +type YearWithMeta = { year: string } & CategoryQueryMeta; + +export type Years = YearWithMeta[]; diff --git a/src/years/meta.ts b/src/years/meta.ts new file mode 100644 index 00000000..ff98fa12 --- /dev/null +++ b/src/years/meta.ts @@ -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, + }; +};