Configurable photo sort order (#277)

* Introduce configurable photo sort order

* Fix recents image pre-rendering

* Refine sort order config

* Store sort order in client state

* Add core views to support sort

* Separate sort and priority preferences

* Consolidate imports, add lint rule

* Refine photo sorting documentation

* Update README sort text

* Finalize sort config
This commit is contained in:
Sam Becker 2025-06-29 21:05:13 -05:00 committed by GitHub
parent c189e567b8
commit d7fbc8bd68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 643 additions and 154 deletions

View File

@ -143,6 +143,17 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content - `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content
- `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos - `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos
#### Sorting
- `NEXT_PUBLIC_DEFAULT_SORT`
- Sets default sort on grid/feed homepages
- Accepted values:
- `taken-at` (default)
- `taken-at-oldest-first`
- `uploaded-at`
- `uploaded-at-oldest-first`
- `NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1` takes priority field into account when sorting photos (⚠️ enabling may have performance consequences)
- `NEXT_PUBLIC_SHOW_SORT_CONTROL = 1` shows sort control in desktop nav on grid/feed homepages
#### Display #### Display
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links - `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography) - `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
@ -165,7 +176,6 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information) - `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage) - `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
- `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml` - `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml`
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top) - `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
## Alternate storage providers ## Alternate storage providers

View File

@ -0,0 +1,53 @@
import {
INFINITE_SCROLL_FEED_INITIAL,
generateOgImageMetaForPhotos,
} from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { cache } from 'react';
import { getPhotos } from '@/photo/db/query';
import PhotoFeedPage from '@/photo/PhotoFeedPage';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/db/sort';
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
import { GetPhotosOptions } from '@/photo/db';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL,
}));
export async function generateMetadata({
params,
}: SortProps): Promise<Metadata> {
const options = await getSortOptionsFromParams(params);
const photos = await getPhotosCached(options)
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function FeedPageSort({ params }: SortProps) {
const options = await getSortOptionsFromParams(params);
const [
photos,
photosCount,
] = await Promise.all([
getPhotosCached(options)
.catch(() => []),
getPhotosMetaCached(options)
.then(({ count }) => count)
.catch(() => 0),
]);
return (
photos.length > 0
? <PhotoFeedPage {...{
photos,
photosCount,
...options,
}} />
: <PhotosEmptyState />
);
}

View File

@ -8,16 +8,19 @@ import { cache } from 'react';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/db/query';
import PhotoFeedPage from '@/photo/PhotoFeedPage'; import PhotoFeedPage from '@/photo/PhotoFeedPage';
import { getPhotosMetaCached } from '@/photo/cache'; import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { GetPhotosOptions } from '@/photo/db';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
export const maxDuration = 60; export const maxDuration = 60;
const getPhotosCached = cache(() => getPhotos({ const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL, limit: INFINITE_SCROLL_FEED_INITIAL,
})); }));
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached() const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []); .catch(() => []);
return generateOgImageMetaForPhotos(photos); return generateOgImageMetaForPhotos(photos);
} }
@ -27,16 +30,20 @@ export default async function FeedPage() {
photos, photos,
photosCount, photosCount,
] = await Promise.all([ ] = await Promise.all([
getPhotosCached() getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []), .catch(() => []),
getPhotosMetaCached() getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
]); ]);
return ( return (
photos.length > 0 photos.length > 0
? <PhotoFeedPage {...{ photos, photosCount }} /> ? <PhotoFeedPage {...{
photos,
photosCount,
...USER_DEFAULT_SORT_OPTIONS,
}} />
: <PhotosEmptyState /> : <PhotosEmptyState />
); );
} }

View File

@ -0,0 +1,59 @@
import {
INFINITE_SCROLL_GRID_INITIAL,
generateOgImageMetaForPhotos,
} from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { getPhotos } from '@/photo/db/query';
import { cache } from 'react';
import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/db/sort';
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
import { GetPhotosOptions } from '@/photo/db';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL,
}));
export async function generateMetadata({
params,
}: SortProps): Promise<Metadata> {
const options = await getSortOptionsFromParams(params);
const photos = await getPhotosCached(options)
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function GridPage({ params }: SortProps) {
const options = await getSortOptionsFromParams(params);
const [
photos,
photosCount,
categories,
] = await Promise.all([
getPhotosCached(options)
.catch(() => []),
getPhotosMetaCached(options)
.then(({ count }) => count)
.catch(() => 0),
getDataForCategoriesCached(),
]);
return (
photos.length > 0
? <PhotoGridPage
{...{
photos,
photosCount,
...options,
...categories,
}}
/>
: <PhotosEmptyState />
);
}

View File

@ -9,15 +9,19 @@ import { cache } from 'react';
import PhotoGridPage from '@/photo/PhotoGridPage'; import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache'; import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache'; import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { GetPhotosOptions } from '@/photo/db';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache(() => getPhotos({ const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL, limit: INFINITE_SCROLL_GRID_INITIAL,
})); }));
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached() const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []); .catch(() => []);
return generateOgImageMetaForPhotos(photos); return generateOgImageMetaForPhotos(photos);
} }
@ -28,9 +32,9 @@ export default async function GridPage() {
photosCount, photosCount,
categories, categories,
] = await Promise.all([ ] = await Promise.all([
getPhotosCached() getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []), .catch(() => []),
getPhotosMetaCached() getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
getDataForCategoriesCached(), getDataForCategoriesCached(),
@ -42,6 +46,7 @@ export default async function GridPage() {
{...{ {...{
photos, photos,
photosCount, photosCount,
...USER_DEFAULT_SORT_OPTIONS,
...categories, ...categories,
}} }}
/> />

View File

@ -7,23 +7,26 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { cache } from 'react'; import { cache } from 'react';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/db/query';
import { GRID_HOMEPAGE_ENABLED } from '@/app/config'; import { GRID_HOMEPAGE_ENABLED, USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { NULL_CATEGORY_DATA } from '@/category/data'; import { NULL_CATEGORY_DATA } from '@/category/data';
import PhotoFeedPage from '@/photo/PhotoFeedPage'; import PhotoFeedPage from '@/photo/PhotoFeedPage';
import PhotoGridPage from '@/photo/PhotoGridPage'; import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache'; import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache'; import { getPhotosMetaCached } from '@/photo/cache';
import { GetPhotosOptions } from '@/photo/db';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
export const maxDuration = 60; export const maxDuration = 60;
const getPhotosCached = cache(() => getPhotos({ const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: GRID_HOMEPAGE_ENABLED limit: GRID_HOMEPAGE_ENABLED
? INFINITE_SCROLL_GRID_INITIAL ? INFINITE_SCROLL_GRID_INITIAL
: INFINITE_SCROLL_FEED_INITIAL, : INFINITE_SCROLL_FEED_INITIAL,
})); }));
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached() const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []); .catch(() => []);
return generateOgImageMetaForPhotos(photos); return generateOgImageMetaForPhotos(photos);
} }
@ -34,9 +37,9 @@ export default async function HomePage() {
photosCount, photosCount,
categories, categories,
] = await Promise.all([ ] = await Promise.all([
getPhotosCached() getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []), .catch(() => []),
getPhotosMetaCached() getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
GRID_HOMEPAGE_ENABLED GRID_HOMEPAGE_ENABLED
@ -51,10 +54,15 @@ export default async function HomePage() {
{...{ {...{
photos, photos,
photosCount, photosCount,
...USER_DEFAULT_SORT_OPTIONS,
...categories, ...categories,
}} }}
/> />
: <PhotoFeedPage {...{ photos, photosCount }} /> : <PhotoFeedPage {...{
photos,
photosCount,
...USER_DEFAULT_SORT_OPTIONS,
}} />
: <PhotosEmptyState /> : <PhotosEmptyState />
); );
} }

View File

@ -17,7 +17,7 @@ export async function GET() {
headers, headers,
] = await Promise.all([ ] = await Promise.all([
getPhotosCached({ getPhotosCached({
sortBy: 'priority', sortWithPriority: true,
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT, limit: MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
}).catch(() => []), }).catch(() => []),
getIBMPlexMono(), getIBMPlexMono(),

View File

@ -17,7 +17,7 @@ export async function GET() {
headers, headers,
] = await Promise.all([ ] = await Promise.all([
getPhotosCached({ getPhotosCached({
sortBy: 'priority', sortWithPriority: true,
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE, limit: MAX_PHOTOS_TO_SHOW_TEMPLATE,
}).catch(() => []), }).catch(() => []),
getIBMPlexMono(), getIBMPlexMono(),

View File

@ -16,6 +16,7 @@ const eslintConfig = [
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-require-imports': 'off',
'no-unused-expressions': ['warn'], 'no-unused-expressions': ['warn'],
'no-duplicate-imports': ['warn'],
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', { 'warn', {
'argsIgnorePattern': '^_', 'argsIgnorePattern': '^_',

View File

@ -12,12 +12,11 @@ import {
BiLockAlt, BiLockAlt,
BiPencil, BiPencil,
} from 'react-icons/bi'; } from 'react-icons/bi';
import { HiOutlineCog } from 'react-icons/hi'; import { HiOutlineCog, HiSparkles } from 'react-icons/hi';
import ChecklistGroup from '@/components/ChecklistGroup'; import ChecklistGroup from '@/components/ChecklistGroup';
import { AppConfiguration } from '../app/config'; import { AppConfiguration } from '../app/config';
import StatusIcon from '@/components/StatusIcon'; import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/platforms/storage'; import { labelForStorage } from '@/platforms/storage';
import { HiSparkles } from 'react-icons/hi';
import { testConnectionsAction } from '@/admin/actions'; import { testConnectionsAction } from '@/admin/actions';
import ErrorNote from '@/components/ErrorNote'; import ErrorNote from '@/components/ErrorNote';
import { RiSpeedMiniLine } from 'react-icons/ri'; import { RiSpeedMiniLine } from 'react-icons/ri';
@ -34,6 +33,8 @@ import clsx from 'clsx/lite';
import Link from 'next/link'; import Link from 'next/link';
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths'; import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
import { FaRegFolderClosed } from 'react-icons/fa6'; import { FaRegFolderClosed } from 'react-icons/fa6';
import IconSort from '@/components/icons/IconSort';
import { APP_DEFAULT_SORT_BY, SORT_BY_OPTIONS } from '@/photo/db/sort';
export default function AdminAppConfigurationClient({ export default function AdminAppConfigurationClient({
// Storage // Storage
@ -85,6 +86,11 @@ export default function AdminAppConfigurationClient({
hasCategoryVisibility, hasCategoryVisibility,
collapseSidebarCategories, collapseSidebarCategories,
hideTagsWithOnePhoto, hideTagsWithOnePhoto,
// Sort
hasDefaultSortBy,
defaultSortBy,
isSortWithPriority,
showSortControl,
// Display // Display
showKeyboardShortcutTooltips, showKeyboardShortcutTooltips,
showExifInfo, showExifInfo,
@ -110,7 +116,6 @@ export default function AdminAppConfigurationClient({
isGeoPrivacyEnabled, isGeoPrivacyEnabled,
arePublicDownloadsEnabled, arePublicDownloadsEnabled,
areSiteFeedsEnabled, areSiteFeedsEnabled,
isPriorityOrderEnabled,
isOgTextBottomAligned, isOgTextBottomAligned,
// Internal // Internal
areInternalToolsEnabled, areInternalToolsEnabled,
@ -140,7 +145,7 @@ export default function AdminAppConfigurationClient({
: null; : null;
const renderEnvVars = (variables: string[]) => const renderEnvVars = (variables: string[]) =>
<div className="pt-1 flex flex-col gap-1"> <div className="pt-1 flex flex-col gap-0.5">
{variables.map(variable => {variables.map(variable =>
<EnvVar key={variable} variable={variable} />)} <EnvVar key={variable} variable={variable} />)}
</div>; </div>;
@ -244,47 +249,49 @@ export default function AdminAppConfigurationClient({
{storageError && renderError({ {storageError && renderError({
connection: { provider: 'Storage', error: storageError}, connection: { provider: 'Storage', error: storageError},
})} })}
{hasVercelBlobStorage <div>
? renderSubStatus('checked', 'Vercel Blob: connected') {hasVercelBlobStorage
: renderSubStatus('optional', <> ? renderSubStatus('checked', 'Vercel Blob: connected')
{labelForStorage('vercel-blob')}: : renderSubStatus('optional', <>
{' '} {labelForStorage('vercel-blob')}:
<AdminLink {' '}
// eslint-disable-next-line max-len <AdminLink
href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store" // eslint-disable-next-line max-len
externalIcon href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store"
> externalIcon
create store >
</AdminLink> create store
{' '} </AdminLink>
and connect to project {' '}
</>, and connect to project
)} </>,
{hasCloudflareR2Storage )}
? renderSubStatus('checked', 'Cloudflare R2: connected') {hasCloudflareR2Storage
: renderSubStatus('optional', <> ? renderSubStatus('checked', 'Cloudflare R2: connected')
{labelForStorage('cloudflare-r2')}: : renderSubStatus('optional', <>
{' '} {labelForStorage('cloudflare-r2')}:
<AdminLink {' '}
// eslint-disable-next-line max-len <AdminLink
href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2" // eslint-disable-next-line max-len
externalIcon href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2"
> externalIcon
create/configure bucket >
</AdminLink> create/configure bucket
</>)} </AdminLink>
{hasAwsS3Storage </>)}
? renderSubStatus('checked', 'AWS S3: connected') {hasAwsS3Storage
: renderSubStatus('optional', <> ? renderSubStatus('checked', 'AWS S3: connected')
{labelForStorage('aws-s3')}: : renderSubStatus('optional', <>
{' '} {labelForStorage('aws-s3')}:
<AdminLink {' '}
href="https://github.com/sambecker/exif-photo-blog#aws-s3" <AdminLink
externalIcon href="https://github.com/sambecker/exif-photo-blog#aws-s3"
> externalIcon
create/configure bucket >
</AdminLink> create/configure bucket
</>)} </AdminLink>
</>)}
</div>
</ChecklistRow> </ChecklistRow>
</ChecklistGroup> </ChecklistGroup>
<ChecklistGroup <ChecklistGroup
@ -428,16 +435,18 @@ export default function AdminAppConfigurationClient({
status={hasAiTextAutoGeneratedFields} status={hasAiTextAutoGeneratedFields}
optional optional
> >
{hasAiTextAutoGeneratedFields && <div>
AI_AUTO_GENERATED_FIELDS_ALL.map(field => {hasAiTextAutoGeneratedFields &&
<Fragment key={field}> AI_AUTO_GENERATED_FIELDS_ALL.map(field =>
{renderSubStatus( <Fragment key={field}>
aiTextAutoGeneratedFields.includes(field) {renderSubStatus(
? 'checked' aiTextAutoGeneratedFields.includes(field)
: 'optional', ? 'checked'
field, : 'optional',
)} field,
</Fragment>)} )}
</Fragment>)}
</div>
Comma-separated fields to auto-generate when Comma-separated fields to auto-generate when
uploading photos. Accepted values: title, caption, uploading photos. Accepted values: title, caption,
tags, description, all, or none tags, description, all, or none
@ -483,23 +492,25 @@ export default function AdminAppConfigurationClient({
Set environment variable to {'"1"'} to make site more responsive Set environment variable to {'"1"'} to make site more responsive
by enabling static optimization by enabling static optimization
(i.e., rendering pages and images at build time): (i.e., rendering pages and images at build time):
{renderSubStatusWithEnvVar( <div>
arePhotosStaticallyOptimized ? 'checked' : 'optional', {renderSubStatusWithEnvVar(
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS', arePhotosStaticallyOptimized ? 'checked' : 'optional',
)} 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
{renderSubStatusWithEnvVar( )}
arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional', {renderSubStatusWithEnvVar(
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES', arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional',
)} 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES',
{renderSubStatusWithEnvVar( )}
arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional', {renderSubStatusWithEnvVar(
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES', arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional',
)} 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES',
{renderSubStatusWithEnvVar( )}
// eslint-disable-next-line max-len {renderSubStatusWithEnvVar(
arePhotoCategoryOgImagesStaticallyOptimized ? 'checked' : 'optional', // eslint-disable-next-line max-len
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES', arePhotoCategoryOgImagesStaticallyOptimized ? 'checked' : 'optional',
)} 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES',
)}
</div>
</ChecklistRow> </ChecklistRow>
<ChecklistRow <ChecklistRow
title="Preserve original uploads" title="Preserve original uploads"
@ -541,7 +552,7 @@ export default function AdminAppConfigurationClient({
status={hasCategoryVisibility} status={hasCategoryVisibility}
optional optional
> >
<div className="my-1"> <div>
{categoryVisibility.map((category, index) => {categoryVisibility.map((category, index) =>
<Fragment key={category}> <Fragment key={category}>
{renderSubStatus( {renderSubStatus(
@ -590,6 +601,50 @@ export default function AdminAppConfigurationClient({
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])} {renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])}
</ChecklistRow> </ChecklistRow>
</ChecklistGroup> </ChecklistGroup>
<ChecklistGroup
title="Sorting"
icon={<IconSort size={18} className="translate-y-[1px]" />}
optional
>
<ChecklistRow
title="Default sort"
status={hasDefaultSortBy}
optional
>
<div>
{SORT_BY_OPTIONS.map(({sortBy, string }) =>
<Fragment key={ sortBy }>
{renderSubStatus(
sortBy === defaultSortBy ? 'checked' : 'optional',
`${string}${sortBy === APP_DEFAULT_SORT_BY
? ' (default)'
: ''}`,
)}
</Fragment>)}
</div>
Change default sort on grid/feed homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
</ChecklistRow>
<ChecklistRow
title="Priority-based sorting"
status={isSortWithPriority}
optional
>
Set environment variable to {'"1"'} to take priority field
into account when sorting photos (enabling may have
performance consequences):
{renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])}
</ChecklistRow>
<ChecklistRow
title="Show sort control"
status={showSortControl}
optional
>
Set environment variable to {'"1"'} to
show sort control in desktop nav on grid/feed homepages:
{renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup <ChecklistGroup
title="Display" title="Display"
icon={<BiHide size={18} />} icon={<BiHide size={18} />}
@ -790,15 +845,6 @@ export default function AdminAppConfigurationClient({
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}: {renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])} {renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
</ChecklistRow> </ChecklistRow>
<ChecklistRow
title="Priority order"
status={isPriorityOrderEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
priority order photo field affecting photo order:
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
</ChecklistRow>
<ChecklistRow <ChecklistRow
title="Legacy OG text alignment" title="Legacy OG text alignment"
status={isOgTextBottomAligned} status={isOgTextBottomAligned}

View File

@ -6,10 +6,10 @@ import {
getUniqueLenses, getUniqueLenses,
getUniqueRecipes, getUniqueRecipes,
getUniqueTags, getUniqueTags,
getPhotosInNeedOfSyncCount,
} from '@/photo/db/query'; } from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient'; import AdminAppInsightsClient from './AdminAppInsightsClient';
import { getAllInsights, getGitHubMetaForCurrentApp } from '.'; import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
import { getPhotosInNeedOfSyncCount } from '@/photo/db/query';
export default async function AdminAppInsights() { export default async function AdminAppInsights() {
const [ const [

View File

@ -3,6 +3,7 @@ import SwitcherItem from '@/components/SwitcherItem';
import IconFeed from '@/components/icons/IconFeed'; import IconFeed from '@/components/icons/IconFeed';
import IconGrid from '@/components/icons/IconGrid'; import IconGrid from '@/components/icons/IconGrid';
import { import {
doesPathOfferSort,
PATH_FEED_INFERRED, PATH_FEED_INFERRED,
PATH_GRID_INFERRED, PATH_GRID_INFERRED,
} from '@/app/paths'; } from '@/app/paths';
@ -11,15 +12,18 @@ import { useAppState } from '@/state/AppState';
import { import {
GRID_HOMEPAGE_ENABLED, GRID_HOMEPAGE_ENABLED,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
SHOW_SORT_CONTROL,
} from './config'; } from './config';
import AdminAppMenu from '@/admin/AdminAppMenu'; import AdminAppMenu from '@/admin/AdminAppMenu';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import useKeydownHandler from '@/utility/useKeydownHandler'; import useKeydownHandler from '@/utility/useKeydownHandler';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { KEY_COMMANDS } from '@/photo/key-commands'; import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import IconSort from '@/components/icons/IconSort';
import { getSortConfigFromPath } from '@/photo/db/sort-path';
export type SwitcherSelection = 'feed' | 'grid' | 'admin'; export type SwitcherSelection = 'feed' | 'grid' | 'admin';
@ -38,8 +42,27 @@ export default function AppViewSwitcher({
isUserSignedIn, isUserSignedIn,
isUserSignedInEager, isUserSignedInEager,
setIsCommandKOpen, setIsCommandKOpen,
invalidateSwr,
} = useAppState(); } = useAppState();
const showSortControl = SHOW_SORT_CONTROL && doesPathOfferSort(pathname);
const {
sortBy,
isAscending,
pathGrid,
pathFeed,
pathSort,
} = getSortConfigFromPath(pathname);
const hasLoadedRef = useRef(false);
useEffect(() => {
if (hasLoadedRef.current) {
// After initial load, invalidate cache every time sort changes
invalidateSwr?.();
}
hasLoadedRef.current = true;
}, [invalidateSwr, sortBy]);
const refHrefFeed = useRef<HTMLAnchorElement>(null); const refHrefFeed = useRef<HTMLAnchorElement>(null);
const refHrefGrid = useRef<HTMLAnchorElement>(null); const refHrefGrid = useRef<HTMLAnchorElement>(null);
@ -65,7 +88,7 @@ export default function AppViewSwitcher({
const renderItemFeed = const renderItemFeed =
<SwitcherItem <SwitcherItem
icon={<IconFeed includeTitle={false} />} icon={<IconFeed includeTitle={false} />}
href={PATH_FEED_INFERRED} href={pathFeed}
hrefRef={refHrefFeed} hrefRef={refHrefFeed}
active={currentSelection === 'feed'} active={currentSelection === 'feed'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
@ -78,7 +101,7 @@ export default function AppViewSwitcher({
const renderItemGrid = const renderItemGrid =
<SwitcherItem <SwitcherItem
icon={<IconGrid includeTitle={false} />} icon={<IconGrid includeTitle={false} />}
href={PATH_GRID_INFERRED} href={pathGrid}
hrefRef={refHrefGrid} hrefRef={refHrefGrid}
active={currentSelection === 'grid'} active={currentSelection === 'grid'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
@ -126,6 +149,21 @@ export default function AppViewSwitcher({
noPadding noPadding
/>} />}
</Switcher> </Switcher>
{showSortControl &&
<Switcher className="max-sm:hidden">
<SwitcherItem
href={pathSort}
icon={<IconSort
sort={isAscending ? 'asc' : 'desc'}
className="translate-x-[0.5px] translate-y-[1px]"
/>}
tooltip={{
content: isAscending
? 'View newest first'
: 'View oldest first',
}}
/>
</Switcher>}
<Switcher type="borderless"> <Switcher type="borderless">
<SwitcherItem <SwitcherItem
icon={<IconSearch includeTitle={false} />} icon={<IconSearch includeTitle={false} />}

View File

@ -8,6 +8,7 @@ import {
makeUrlAbsolute, makeUrlAbsolute,
shortenUrl, shortenUrl,
} from '@/utility/url'; } from '@/utility/url';
import { getSortByFromString } from '@/photo/db/sort';
// HARD-CODED GLOBAL CONFIGURATION // HARD-CODED GLOBAL CONFIGURATION
@ -267,6 +268,19 @@ export const COLLAPSE_SIDEBAR_CATEGORIES =
export const HIDE_TAGS_WITH_ONE_PHOTO = export const HIDE_TAGS_WITH_ONE_PHOTO =
process.env.NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO === '1'; process.env.NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO === '1';
// SORT
export const USER_DEFAULT_SORT_BY =
getSortByFromString(process.env.NEXT_PUBLIC_DEFAULT_SORT);
export const USER_DEFAULT_SORT_WITH_PRIORITY =
process.env.NEXT_PUBLIC_PRIORITY_BASED_SORTING === '1';
export const USER_DEFAULT_SORT_OPTIONS = {
sortBy: USER_DEFAULT_SORT_BY,
sortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
};
export const SHOW_SORT_CONTROL =
process.env.NEXT_PUBLIC_SHOW_SORT_CONTROL === '1';
// DISPLAY // DISPLAY
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS = export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
@ -321,8 +335,6 @@ export const ALLOW_PUBLIC_DOWNLOADS =
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1'; process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
export const SITE_FEEDS_ENABLED = export const SITE_FEEDS_ENABLED =
process.env.NEXT_PUBLIC_SITE_FEEDS === '1'; process.env.NEXT_PUBLIC_SITE_FEEDS === '1';
export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const OG_TEXT_BOTTOM_ALIGNMENT = export const OG_TEXT_BOTTOM_ALIGNMENT =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM'; (process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
@ -405,6 +417,11 @@ export const APP_CONFIGURATION = {
categoryVisibility: CATEGORY_VISIBILITY, categoryVisibility: CATEGORY_VISIBILITY,
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES, collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO, hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
// Sort
hasDefaultSortBy: Boolean(process.env.NEXT_PUBLIC_DEFAULT_SORT),
defaultSortBy: USER_DEFAULT_SORT_BY,
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
showSortControl: SHOW_SORT_CONTROL,
// Display // Display
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
showExifInfo: SHOW_EXIF_DATA, showExifInfo: SHOW_EXIF_DATA,
@ -433,7 +450,6 @@ export const APP_CONFIGURATION = {
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED, isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS, arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
areSiteFeedsEnabled: SITE_FEEDS_ENABLED, areSiteFeedsEnabled: SITE_FEEDS_ENABLED,
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT, isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
// Internal // Internal
areInternalToolsEnabled: ( areInternalToolsEnabled: (

View File

@ -6,7 +6,7 @@ import { parameterize } from '@/utility/string';
import { TAG_HIDDEN } from '@/tag'; import { TAG_HIDDEN } from '@/tag';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
// Core paths // Core
export const PATH_ROOT = '/'; export const PATH_ROOT = '/';
export const PATH_GRID = '/grid'; export const PATH_GRID = '/grid';
export const PATH_FEED = '/feed'; export const PATH_FEED = '/feed';
@ -15,18 +15,29 @@ export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in'; export const PATH_SIGN_IN = '/sign-in';
export const PATH_OG = '/og'; export const PATH_OG = '/og';
// Feeds // Core: inferred
export const PATH_FEED_JSON = '/feed.json';
export const PATH_RSS_XML = '/rss.xml';
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_ROOT ? PATH_ROOT
: PATH_GRID; : PATH_GRID;
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_FEED ? PATH_FEED
: PATH_ROOT; : PATH_ROOT;
// Sort
export const PARAM_SORT_TYPE_TAKEN_AT = 'taken-at';
export const PARAM_SORT_TYPE_UPLOADED_AT = 'uploaded-at';
export const PARAM_SORT_ORDER_NEWEST = 'newest-first';
export const PARAM_SORT_ORDER_OLDEST = 'oldest-first';
export const doesPathOfferSort = (pathname: string) =>
pathname === PATH_ROOT ||
pathname.startsWith(PATH_GRID) ||
pathname.startsWith(PATH_FEED);
// Feeds
export const PATH_SITEMAP = '/sitemap.xml';
export const PATH_RSS_XML = '/rss.xml';
export const PATH_FEED_JSON = '/feed.json';
// Path prefixes // Path prefixes
export const PREFIX_PHOTO = '/p'; export const PREFIX_PHOTO = '/p';
export const PREFIX_CAMERA = '/shot-on'; export const PREFIX_CAMERA = '/shot-on';

View File

@ -1,6 +1,5 @@
import { Photo } from '../photo'; import { Photo, PhotoDateRange } from '../photo';
import { Camera, Cameras } from '@/camera'; import { Camera, Cameras } from '@/camera';
import { PhotoDateRange } from '../photo';
import { Films } from '@/film'; import { Films } from '@/film';
import { Lens, Lenses } from '@/lens'; import { Lens, Lenses } from '@/lens';
import { Tags } from '@/tag'; import { Tags } from '@/tag';

View File

@ -1,7 +1,5 @@
import { createCameraKey } from '@/camera'; import { createCameraKey, Camera } from '@/camera';
import { createLensKey } from '@/lens'; import { createLensKey, Lens } from '@/lens';
import { Camera } from '@/camera';
import { Lens } from '@/lens';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';

View File

@ -40,7 +40,7 @@ export default function ChecklistRow({
{experimental && {experimental &&
<ExperimentalBadge className="translate-y-[0.5px]" />} <ExperimentalBadge className="translate-y-[0.5px]" />}
</div> </div>
<div className="leading-relaxed text-medium"> <div className="leading-relaxed text-medium space-y-1.5">
{children} {children}
</div> </div>
</>} </>}

View File

@ -4,9 +4,11 @@ import { clsx } from 'clsx/lite';
export default function Switcher({ export default function Switcher({
children, children,
type = 'regular', type = 'regular',
className,
}: { }: {
children: ReactNode children: ReactNode
type?: 'regular' | 'borderless' type?: 'regular' | 'borderless'
className?: string
}) { }) {
return ( return (
<div className={clsx( <div className={clsx(
@ -17,6 +19,7 @@ export default function Switcher({
'divide-medium', 'divide-medium',
type === 'regular' && type === 'regular' &&
'outline-medium shadow-[0_2px_4px_rgba(0,0,0,0.07)]', 'outline-medium shadow-[0_2px_4px_rgba(0,0,0,0.07)]',
className,
)}> )}>
{children} {children}
</div> </div>

View File

@ -61,6 +61,7 @@ export default function SwitcherItem({
href, href,
ref: hrefRef, ref: hrefRef,
title, title,
onClick,
className, className,
prefetch, prefetch,
icon: renderIcon(), icon: renderIcon(),

View File

@ -0,0 +1,11 @@
import { IconBaseProps } from 'react-icons';
import { HiSortAscending, HiSortDescending } from 'react-icons/hi';
export default function IconSort({
sort = 'desc',
...props
}: IconBaseProps & { sort?: 'desc' | 'asc' }) {
return sort === 'desc'
? <HiSortDescending size={17} {...props} />
: <HiSortAscending size={17} {...props} />;
}

View File

@ -9,8 +9,7 @@ import {
} from '.'; } from '.';
import { formatDateFromPostgresString } from '@/utility/date'; import { formatDateFromPostgresString } from '@/utility/date';
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import { BASE_URL, META_DESCRIPTION } from '@/app/config'; import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
import { META_TITLE } from '@/app/config';
interface FeedPhotoJson { interface FeedPhotoJson {
id: string id: string

View File

@ -15,9 +15,9 @@ import { Photo } from '.';
import { PhotoSetCategory } from '../category'; import { PhotoSetCategory } from '../category';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { GetPhotosOptions } from './db';
import useVisible from '@/utility/useVisible'; import useVisible from '@/utility/useVisible';
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config'; import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
import { SortBy } from './db/sort';
export type RevalidatePhoto = ( export type RevalidatePhoto = (
photoId: string, photoId: string,
@ -29,6 +29,7 @@ export default function InfinitePhotoScroll({
initialOffset, initialOffset,
itemsPerPage, itemsPerPage,
sortBy, sortBy,
sortWithPriority,
camera, camera,
lens, lens,
tag, tag,
@ -42,7 +43,8 @@ export default function InfinitePhotoScroll({
}: { }: {
initialOffset: number initialOffset: number
itemsPerPage: number itemsPerPage: number
sortBy?: GetPhotosOptions['sortBy'] sortBy?: SortBy
sortWithPriority?: boolean
cacheKey: string cacheKey: string
wrapMoreButtonInGrid?: boolean wrapMoreButtonInGrid?: boolean
useCachedPhotos?: boolean useCachedPhotos?: boolean
@ -69,7 +71,8 @@ export default function InfinitePhotoScroll({
) => ) =>
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({ (useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
offset: initialOffset + size * itemsPerPage, offset: initialOffset + size * itemsPerPage,
sortBy, sortBy,
sortWithPriority,
limit: itemsPerPage, limit: itemsPerPage,
hidden: includeHiddenPhotos ? 'include' : 'exclude', hidden: includeHiddenPhotos ? 'include' : 'exclude',
camera, camera,
@ -82,6 +85,7 @@ export default function InfinitePhotoScroll({
, [ , [
useCachedPhotos, useCachedPhotos,
sortBy, sortBy,
sortWithPriority,
initialOffset, initialOffset,
itemsPerPage, itemsPerPage,
includeHiddenPhotos, includeHiddenPhotos,

View File

@ -4,19 +4,26 @@ import {
} from '.'; } from '.';
import PhotosLarge from './PhotosLarge'; import PhotosLarge from './PhotosLarge';
import PhotosLargeInfinite from './PhotosLargeInfinite'; import PhotosLargeInfinite from './PhotosLargeInfinite';
import { SortBy } from './db/sort';
export default function PhotoFeedPage({ export default function PhotoFeedPage({
photos, photos,
photosCount, photosCount,
sortBy,
sortWithPriority,
}:{ }:{
photos: Photo[] photos: Photo[]
photosCount: number photosCount: number
sortBy: SortBy
sortWithPriority: boolean
}) { }) {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<PhotosLarge {...{ photos }} /> <PhotosLarge {...{ photos }} />
{photosCount > photos.length && {photosCount > photos.length &&
<PhotosLargeInfinite <PhotosLargeInfinite
sortBy={sortBy}
sortWithPriority={sortWithPriority}
initialOffset={photos.length} initialOffset={photos.length}
itemsPerPage={INFINITE_SCROLL_FEED_MULTIPLE} itemsPerPage={INFINITE_SCROLL_FEED_MULTIPLE}
/>} />}

View File

@ -7,11 +7,14 @@ import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { ComponentProps, useCallback, useState, ReactNode } from 'react'; import { ComponentProps, useCallback, useState, ReactNode } from 'react';
import { GRID_SPACE_CLASSNAME } from '@/components'; import { GRID_SPACE_CLASSNAME } from '@/components';
import { SortBy } from './db/sort';
export default function PhotoGridContainer({ export default function PhotoGridContainer({
cacheKey, cacheKey,
photos, photos,
count, count,
sortBy,
sortWithPriority,
animateOnFirstLoadOnly, animateOnFirstLoadOnly,
header, header,
sidebar, sidebar,
@ -20,6 +23,8 @@ export default function PhotoGridContainer({
}: { }: {
cacheKey: string cacheKey: string
count: number count: number
sortBy?: SortBy
sortWithPriority?: boolean
header?: ReactNode header?: ReactNode
sidebar?: ReactNode sidebar?: ReactNode
} & ComponentProps<typeof PhotoGrid>) { } & ComponentProps<typeof PhotoGrid>) {
@ -54,6 +59,8 @@ export default function PhotoGridContainer({
<PhotoGridInfinite {...{ <PhotoGridInfinite {...{
cacheKey, cacheKey,
initialOffset: photos.length, initialOffset: photos.length,
sortBy,
sortWithPriority,
...categories, ...categories,
canStart: shouldAnimateDynamicItems, canStart: shouldAnimateDynamicItems,
animateOnFirstLoadOnly, animateOnFirstLoadOnly,

View File

@ -4,10 +4,13 @@ import { INFINITE_SCROLL_GRID_MULTIPLE } from '.';
import InfinitePhotoScroll from './InfinitePhotoScroll'; import InfinitePhotoScroll from './InfinitePhotoScroll';
import PhotoGrid from './PhotoGrid'; import PhotoGrid from './PhotoGrid';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { SortBy } from './db/sort';
export default function PhotoGridInfinite({ export default function PhotoGridInfinite({
cacheKey, cacheKey,
initialOffset, initialOffset,
sortBy,
sortWithPriority,
canStart, canStart,
animateOnFirstLoadOnly, animateOnFirstLoadOnly,
canSelect, canSelect,
@ -15,12 +18,16 @@ export default function PhotoGridInfinite({
}: { }: {
cacheKey: string cacheKey: string
initialOffset: number initialOffset: number
sortBy?: SortBy
sortWithPriority?: boolean
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) { } & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
return ( return (
<InfinitePhotoScroll <InfinitePhotoScroll
cacheKey={cacheKey} cacheKey={cacheKey}
initialOffset={initialOffset} initialOffset={initialOffset}
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE} itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
{...categories} {...categories}
> >
{({ photos, onLastPhotoVisible }) => {({ photos, onLastPhotoVisible }) =>

View File

@ -10,14 +10,19 @@ import clsx from 'clsx/lite';
import useElementHeight from '@/utility/useElementHeight'; import useElementHeight from '@/utility/useElementHeight';
import MaskedScroll from '@/components/MaskedScroll'; import MaskedScroll from '@/components/MaskedScroll';
import { IS_RECENTS_FIRST } from '@/app/config'; import { IS_RECENTS_FIRST } from '@/app/config';
import { SortBy } from './db/sort';
export default function PhotoGridPageClient({ export default function PhotoGridPageClient({
photos, photos,
photosCount, photosCount,
sortBy,
sortWithPriority,
...categories ...categories
}: ComponentProps<typeof PhotoGridSidebar> & { }: ComponentProps<typeof PhotoGridSidebar> & {
photos: Photo[] photos: Photo[]
photosCount: number photosCount: number
sortBy: SortBy
sortWithPriority: boolean
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -35,6 +40,8 @@ export default function PhotoGridPageClient({
cacheKey={`page-${PATH_GRID_INFERRED}`} cacheKey={`page-${PATH_GRID_INFERRED}`}
photos={photos} photos={photos}
count={photosCount} count={photosCount}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
sidebar={ sidebar={
<MaskedScroll <MaskedScroll
ref={ref} ref={ref}

View File

@ -6,9 +6,7 @@ import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
import ImageInput from '../components/ImageInput'; import ImageInput from '../components/ImageInput';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { RefObject, useTransition } from 'react'; import { RefObject, useTransition, useRef, useEffect } from 'react';
import { useRef } from 'react';
import { useEffect } from 'react';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';

View File

@ -3,19 +3,24 @@
import { PATH_FEED_INFERRED } from '@/app/paths'; import { PATH_FEED_INFERRED } from '@/app/paths';
import InfinitePhotoScroll from './InfinitePhotoScroll'; import InfinitePhotoScroll from './InfinitePhotoScroll';
import PhotosLarge from './PhotosLarge'; import PhotosLarge from './PhotosLarge';
import { SortBy } from './db/sort';
export default function PhotosLargeInfinite({ export default function PhotosLargeInfinite({
initialOffset, initialOffset,
itemsPerPage, itemsPerPage,
sortBy,
}: { }: {
initialOffset: number initialOffset: number
itemsPerPage: number itemsPerPage: number
sortBy: SortBy
sortWithPriority: boolean
}) { }) {
return ( return (
<InfinitePhotoScroll <InfinitePhotoScroll
cacheKey={`page-${PATH_FEED_INFERRED}`} cacheKey={`page-${PATH_FEED_INFERRED}`}
initialOffset={initialOffset} initialOffset={initialOffset}
itemsPerPage={itemsPerPage} itemsPerPage={itemsPerPage}
sortBy={sortBy}
wrapMoreButtonInGrid wrapMoreButtonInGrid
> >
{({ photos, onLastPhotoVisible, revalidatePhoto }) => {({ photos, onLastPhotoVisible, revalidatePhoto }) =>

View File

@ -1,8 +1,8 @@
import { PRIORITY_ORDER_ENABLED } from '@/app/config';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { PhotoSetCategory } from '../../category'; import { PhotoSetCategory } from '../../category';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
import { APP_DEFAULT_SORT_BY, SortBy } from './sort';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000; export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100; export const PHOTO_DEFAULT_LIMIT = 100;
@ -20,7 +20,8 @@ const parameterizeForDb = (field: string) =>
, `LOWER(TRIM(${field}))`); , `LOWER(TRIM(${field}))`);
export type GetPhotosOptions = { export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority' sortBy?: SortBy
sortWithPriority?: boolean
limit?: number limit?: number
offset?: number offset?: number
query?: string query?: string
@ -146,18 +147,27 @@ export const getWheresFromOptions = (
export const getOrderByFromOptions = (options: GetPhotosOptions) => { export const getOrderByFromOptions = (options: GetPhotosOptions) => {
const { const {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt', sortBy = APP_DEFAULT_SORT_BY,
sortWithPriority,
} = options; } = options;
switch (sortBy) { switch (sortBy) {
case 'createdAt':
return 'ORDER BY created_at DESC';
case 'createdAtAsc':
return 'ORDER BY created_at ASC';
case 'takenAt': case 'takenAt':
return 'ORDER BY taken_at DESC'; return sortWithPriority
case 'priority': ? 'ORDER BY priority_order ASC, taken_at DESC'
return 'ORDER BY priority_order ASC, taken_at DESC'; : 'ORDER BY taken_at DESC';
case 'takenAtAsc':
return sortWithPriority
? 'ORDER BY priority_order ASC, taken_at ASC'
: 'ORDER BY taken_at ASC';
case 'createdAt':
return sortWithPriority
? 'ORDER BY priority_order ASC, created_at DESC'
: 'ORDER BY created_at DESC';
case 'createdAtAsc':
return sortWithPriority
? 'ORDER BY priority_order ASC, created_at ASC'
: 'ORDER BY created_at ASC';
} }
}; };

View File

@ -22,10 +22,10 @@ import {
} from '@/app/config'; } from '@/app/config';
import { import {
GetPhotosOptions, GetPhotosOptions,
getLimitAndOffsetFromOptions,
getOrderByFromOptions, getOrderByFromOptions,
getLimitAndOffsetFromOptions,
getWheresFromOptions,
} from '.'; } from '.';
import { getWheresFromOptions } from '.';
import { FocalLengths } from '@/focal'; import { FocalLengths } from '@/focal';
import { Lenses, createLensKey } from '@/lens'; import { Lenses, createLensKey } from '@/lens';
import { migrationForError } from './migration'; import { migrationForError } from './migration';
@ -120,7 +120,7 @@ const safelyQueryPhotos = async <T>(
} }
} }
} else if (/relation "photos" does not exist/i.test(e.message)) { } else if (/relation "photos" does not exist/i.test(e.message)) {
// If the table does not exist, create it // If table doesn't exist, create it
console.log('Creating photos table ...'); console.log('Creating photos table ...');
await createPhotosTable(); await createPhotosTable();
result = await callback(); result = await callback();

128
src/photo/db/sort-path.ts Normal file
View File

@ -0,0 +1,128 @@
// 'sort-path.ts' separate from 'sort.ts'
// to avoid circular dependencies
import {
PARAM_SORT_ORDER_NEWEST,
PARAM_SORT_ORDER_OLDEST,
PARAM_SORT_TYPE_TAKEN_AT,
PARAM_SORT_TYPE_UPLOADED_AT,
PATH_FEED,
PATH_FEED_INFERRED,
PATH_GRID,
PATH_GRID_INFERRED,
} from '@/app/paths';
import { SortBy, SortParams } from './sort';
import {
USER_DEFAULT_SORT_BY,
GRID_HOMEPAGE_ENABLED,
USER_DEFAULT_SORT_WITH_PRIORITY,
} from '@/app/config';
export const getSortByComponents = (sortBy: SortBy): {
sortType: string
sortOrder: string
} => {
switch (sortBy) {
case 'takenAt': return {
sortType: PARAM_SORT_TYPE_TAKEN_AT,
sortOrder: PARAM_SORT_ORDER_NEWEST,
};
case 'takenAtAsc': return {
sortType: PARAM_SORT_TYPE_TAKEN_AT,
sortOrder: PARAM_SORT_ORDER_OLDEST,
};
case 'createdAt': return {
sortType: PARAM_SORT_TYPE_UPLOADED_AT,
sortOrder: PARAM_SORT_ORDER_NEWEST,
};
case 'createdAtAsc': return {
sortType: PARAM_SORT_TYPE_UPLOADED_AT,
sortOrder: PARAM_SORT_ORDER_OLDEST,
};
}
};
const {
sortType: DEFAULT_SORT_TYPE,
sortOrder: DEFAULT_SORT_ORDER,
} = getSortByComponents(USER_DEFAULT_SORT_BY);
const _getSortOptionsFromParams = (
sortType = DEFAULT_SORT_TYPE,
sortOrder = DEFAULT_SORT_ORDER,
): {
sortBy: SortBy
sortWithPriority: boolean
} => {
let sortBy: SortBy = 'takenAt';
const isAscending = sortOrder === PARAM_SORT_ORDER_OLDEST;
switch (sortType) {
case PARAM_SORT_TYPE_TAKEN_AT: {
sortBy = isAscending
? 'takenAtAsc'
: 'takenAt';
break;
}
case PARAM_SORT_TYPE_UPLOADED_AT: {
sortBy = isAscending
? 'createdAtAsc'
: 'createdAt';
break;
}
}
return {
sortBy,
sortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
};
};
export const getSortOptionsFromParams = async (
params: SortParams,
): Promise<ReturnType<typeof _getSortOptionsFromParams>> => {
const { sortType, sortOrder } = await params;
return _getSortOptionsFromParams(sortType, sortOrder);
};
export const getPathSortComponents = (pathname: string) => {
const [_, gridOrFeed, sortType, sortOrder] = pathname.split('/');
return {
gridOrFeed: gridOrFeed || (GRID_HOMEPAGE_ENABLED
? 'grid'
: 'feed'
),
sortType: sortType || DEFAULT_SORT_TYPE,
sortOrder: sortOrder || DEFAULT_SORT_ORDER,
};
};
const getReversedSortOrder = (sortOrder: string): string =>
sortOrder === PARAM_SORT_ORDER_OLDEST
? PARAM_SORT_ORDER_NEWEST
: PARAM_SORT_ORDER_OLDEST;
export const getSortConfigFromPath = (pathname: string) => {
const { gridOrFeed, sortType, sortOrder } = getPathSortComponents(pathname);
const { sortBy } = _getSortOptionsFromParams(sortType, sortOrder);
const isSortedByDefault = sortBy === USER_DEFAULT_SORT_BY;
const reversedSortOrder = getReversedSortOrder(sortOrder);
const isAscending = sortOrder === PARAM_SORT_ORDER_OLDEST;
const doesReverseSortMatchDefault = _getSortOptionsFromParams(
sortType,
reversedSortOrder,
).sortBy === USER_DEFAULT_SORT_BY;
return {
sortBy,
isAscending,
pathGrid: isSortedByDefault
? PATH_GRID_INFERRED
: `${PATH_GRID}/${sortType}/${sortOrder}`,
pathFeed: isSortedByDefault
? PATH_FEED_INFERRED
: `${PATH_FEED}/${sortType}/${sortOrder}`,
pathSort: doesReverseSortMatchDefault
? gridOrFeed === 'grid'
? PATH_GRID_INFERRED
: PATH_FEED_INFERRED
: `/${gridOrFeed}/${sortType}/${reversedSortOrder}`,
};
};

43
src/photo/db/sort.ts Normal file
View File

@ -0,0 +1,43 @@
export const SORT_BY_OPTIONS = [{
sortBy: 'takenAt',
string: 'taken-at',
label: 'Taken At (Newest First)',
}, {
sortBy: 'takenAtAsc',
string: 'taken-at-oldest-first',
label: 'Taken At (Oldest First)',
}, {
sortBy: 'createdAt',
string: 'uploaded-at',
label: 'Uploaded At (Newest First)',
}, {
sortBy: 'createdAtAsc',
string: 'uploaded-at-oldest-first',
label: 'Uploaded At (Oldest First)',
}] as const;
export type SortBy = (typeof SORT_BY_OPTIONS)[number]['sortBy'];
export const APP_DEFAULT_SORT_BY: SortBy = 'takenAt';
export type SortParams = Promise<{
sortType: string
sortOrder: string
}>
export interface SortProps {
params: SortParams
}
export const getSortByFromString = (sortBy = ''): SortBy => {
switch (sortBy) {
case 'taken-at': return 'takenAt';
case 'taken-at-oldest-first': return 'takenAtAsc';
case 'uploaded-at': return 'createdAt';
case 'uploaded-at-oldest-first': return 'createdAtAsc';
default:return 'takenAt';
}
};
export const isSortAscending = (sortBy: SortBy) =>
sortBy === 'takenAtAsc' || sortBy === 'createdAtAsc';

View File

@ -1,11 +1,13 @@
import { import {
convertApertureValueToFNumber, convertApertureValueToFNumber,
getAspectRatioFromExif, getAspectRatioFromExif,
getOffsetFromExif,
} from '@/utility/exif'; } from '@/utility/exif';
import {
convertTimestampWithOffsetToPostgresString,
convertTimestampToNaivePostgresString,
} from '@/utility/date';
import { GEO_PRIVACY_ENABLED } from '@/app/config'; import { GEO_PRIVACY_ENABLED } from '@/app/config';
import { convertTimestampWithOffsetToPostgresString } from '@/utility/date';
import { convertTimestampToNaivePostgresString } from '@/utility/date';
import { getOffsetFromExif } from '@/utility/exif';
import { PhotoExif } from '..'; import { PhotoExif } from '..';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';

View File

@ -1,9 +1,8 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange, descriptionForPhotoSet } from '@/photo';
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths'; import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { descriptionForPhotoSet } from '@/photo';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
export default function RecentsOGTile({ export default function RecentsOGTile({

View File

@ -1,6 +1,10 @@
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths'; import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
import { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo'; import {
import { PhotoDateRange } from '@/photo'; descriptionForPhotoSet,
Photo,
photoQuantityText,
PhotoDateRange,
} from '@/photo';
import { import {
capitalizeWords, capitalizeWords,
formatCount, formatCount,

View File

@ -1,6 +1,12 @@
'use client'; 'use client';
import { useState, useEffect, ReactNode, useCallback, useRef } from 'react'; import {
useState,
useEffect,
ReactNode,
useCallback,
useRef,
} from 'react';
import { AppStateContext } from './AppState'; import { AppStateContext } from './AppState';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames'; import usePathnames from '@/utility/usePathnames';

View File

@ -1,6 +1,4 @@
import { useState } from 'react'; import { useState, RefObject, useEffect } from 'react';
import { RefObject, useEffect } from 'react';
export default function useElementHeight( export default function useElementHeight(
ref: RefObject<HTMLElement | null>, ref: RefObject<HTMLElement | null>,

View File

@ -1,9 +1,8 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange, descriptionForPhotoSet } from '@/photo';
import { pathForYear, pathForYearImage } from '@/app/paths'; import { pathForYear, pathForYearImage } from '@/app/paths';
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { descriptionForPhotoSet } from '@/photo';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
export default function YearOGTile({ export default function YearOGTile({