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_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
- `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)
@ -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_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_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)
## 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 PhotoFeedPage from '@/photo/PhotoFeedPage';
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 maxDuration = 60;
const getPhotosCached = cache(() => getPhotos({
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL,
}));
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached()
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
@ -27,16 +30,20 @@ export default async function FeedPage() {
photos,
photosCount,
] = await Promise.all([
getPhotosCached()
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []),
getPhotosMetaCached()
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
]);
return (
photos.length > 0
? <PhotoFeedPage {...{ photos, photosCount }} />
? <PhotoFeedPage {...{
photos,
photosCount,
...USER_DEFAULT_SORT_OPTIONS,
}} />
: <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 { getDataForCategoriesCached } from '@/category/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 maxDuration = 60;
const getPhotosCached = cache(() => getPhotos({
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL,
}));
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached()
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
@ -28,9 +32,9 @@ export default async function GridPage() {
photosCount,
categories,
] = await Promise.all([
getPhotosCached()
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
.catch(() => []),
getPhotosMetaCached()
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
getDataForCategoriesCached(),
@ -42,6 +46,7 @@ export default async function GridPage() {
{...{
photos,
photosCount,
...USER_DEFAULT_SORT_OPTIONS,
...categories,
}}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import SwitcherItem from '@/components/SwitcherItem';
import IconFeed from '@/components/icons/IconFeed';
import IconGrid from '@/components/icons/IconGrid';
import {
doesPathOfferSort,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
} from '@/app/paths';
@ -11,15 +12,18 @@ import { useAppState } from '@/state/AppState';
import {
GRID_HOMEPAGE_ENABLED,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
SHOW_SORT_CONTROL,
} from './config';
import AdminAppMenu from '@/admin/AdminAppMenu';
import Spinner from '@/components/Spinner';
import clsx from 'clsx/lite';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import useKeydownHandler from '@/utility/useKeydownHandler';
import { usePathname } from 'next/navigation';
import { KEY_COMMANDS } from '@/photo/key-commands';
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';
@ -38,8 +42,27 @@ export default function AppViewSwitcher({
isUserSignedIn,
isUserSignedInEager,
setIsCommandKOpen,
invalidateSwr,
} = 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 refHrefGrid = useRef<HTMLAnchorElement>(null);
@ -65,7 +88,7 @@ export default function AppViewSwitcher({
const renderItemFeed =
<SwitcherItem
icon={<IconFeed includeTitle={false} />}
href={PATH_FEED_INFERRED}
href={pathFeed}
hrefRef={refHrefFeed}
active={currentSelection === 'feed'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
@ -78,7 +101,7 @@ export default function AppViewSwitcher({
const renderItemGrid =
<SwitcherItem
icon={<IconGrid includeTitle={false} />}
href={PATH_GRID_INFERRED}
href={pathGrid}
hrefRef={refHrefGrid}
active={currentSelection === 'grid'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
@ -126,6 +149,21 @@ export default function AppViewSwitcher({
noPadding
/>}
</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">
<SwitcherItem
icon={<IconSearch includeTitle={false} />}

View File

@ -8,6 +8,7 @@ import {
makeUrlAbsolute,
shortenUrl,
} from '@/utility/url';
import { getSortByFromString } from '@/photo/db/sort';
// HARD-CODED GLOBAL CONFIGURATION
@ -267,6 +268,19 @@ export const COLLAPSE_SIDEBAR_CATEGORIES =
export const HIDE_TAGS_WITH_ONE_PHOTO =
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
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
@ -321,8 +335,6 @@ export const ALLOW_PUBLIC_DOWNLOADS =
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
export const SITE_FEEDS_ENABLED =
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 =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
@ -405,6 +417,11 @@ export const APP_CONFIGURATION = {
categoryVisibility: CATEGORY_VISIBILITY,
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
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
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
showExifInfo: SHOW_EXIF_DATA,
@ -433,7 +450,6 @@ export const APP_CONFIGURATION = {
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
areSiteFeedsEnabled: SITE_FEEDS_ENABLED,
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
// Internal
areInternalToolsEnabled: (

View File

@ -6,7 +6,7 @@ import { parameterize } from '@/utility/string';
import { TAG_HIDDEN } from '@/tag';
import { Lens } from '@/lens';
// Core paths
// Core
export const PATH_ROOT = '/';
export const PATH_GRID = '/grid';
export const PATH_FEED = '/feed';
@ -15,18 +15,29 @@ export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in';
export const PATH_OG = '/og';
// Feeds
export const PATH_FEED_JSON = '/feed.json';
export const PATH_RSS_XML = '/rss.xml';
// Core: inferred
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_ROOT
: PATH_GRID;
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_FEED
: 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
export const PREFIX_PHOTO = '/p';
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 { PhotoDateRange } from '../photo';
import { Films } from '@/film';
import { Lens, Lenses } from '@/lens';
import { Tags } from '@/tag';

View File

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

View File

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

View File

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

View File

@ -61,6 +61,7 @@ export default function SwitcherItem({
href,
ref: hrefRef,
title,
onClick,
className,
prefetch,
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 '.';
import { formatDateFromPostgresString } from '@/utility/date';
import { Photo } from '@/photo';
import { BASE_URL, META_DESCRIPTION } from '@/app/config';
import { META_TITLE } from '@/app/config';
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
interface FeedPhotoJson {
id: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { PRIORITY_ORDER_ENABLED } from '@/app/config';
import { parameterize } from '@/utility/string';
import { PhotoSetCategory } from '../../category';
import { Camera } from '@/camera';
import { Lens } from '@/lens';
import { APP_DEFAULT_SORT_BY, SortBy } from './sort';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100;
@ -20,7 +20,8 @@ const parameterizeForDb = (field: string) =>
, `LOWER(TRIM(${field}))`);
export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority'
sortBy?: SortBy
sortWithPriority?: boolean
limit?: number
offset?: number
query?: string
@ -146,18 +147,27 @@ export const getWheresFromOptions = (
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
const {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
sortBy = APP_DEFAULT_SORT_BY,
sortWithPriority,
} = options;
switch (sortBy) {
case 'createdAt':
return 'ORDER BY created_at DESC';
case 'createdAtAsc':
return 'ORDER BY created_at ASC';
case 'takenAt':
return 'ORDER BY taken_at DESC';
case 'priority':
return 'ORDER BY priority_order ASC, taken_at DESC';
return sortWithPriority
? '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';
import {
GetPhotosOptions,
getLimitAndOffsetFromOptions,
getOrderByFromOptions,
getLimitAndOffsetFromOptions,
getWheresFromOptions,
} from '.';
import { getWheresFromOptions } from '.';
import { FocalLengths } from '@/focal';
import { Lenses, createLensKey } from '@/lens';
import { migrationForError } from './migration';
@ -120,7 +120,7 @@ const safelyQueryPhotos = async <T>(
}
}
} 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 ...');
await createPhotosTable();
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 {
convertApertureValueToFNumber,
getAspectRatioFromExif,
getOffsetFromExif,
} from '@/utility/exif';
import {
convertTimestampWithOffsetToPostgresString,
convertTimestampToNaivePostgresString,
} from '@/utility/date';
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 { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';

View File

@ -1,9 +1,8 @@
'use client';
import { Photo, PhotoDateRange } from '@/photo';
import { Photo, PhotoDateRange, descriptionForPhotoSet } 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({

View File

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

View File

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

View File

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

View File

@ -1,9 +1,8 @@
'use client';
import { Photo, PhotoDateRange } from '@/photo';
import { Photo, PhotoDateRange, descriptionForPhotoSet } 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({