diff --git a/README.md b/README.md index 82d7c3c3..5833afb7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/feed/[sortType]/[sortOrder]/page.tsx b/app/feed/[sortType]/[sortOrder]/page.tsx new file mode 100644 index 00000000..a047a7ed --- /dev/null +++ b/app/feed/[sortType]/[sortOrder]/page.tsx @@ -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 { + 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 + ? + : + ); +} diff --git a/app/feed/page.tsx b/app/feed/page.tsx index 318fe75e..3628396e 100644 --- a/app/feed/page.tsx +++ b/app/feed/page.tsx @@ -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 { - 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 - ? + ? : ); } diff --git a/app/grid/[sortType]/[sortOrder]/page.tsx b/app/grid/[sortType]/[sortOrder]/page.tsx new file mode 100644 index 00000000..b9596aca --- /dev/null +++ b/app/grid/[sortType]/[sortOrder]/page.tsx @@ -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 { + 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 + ? + : + ); +} diff --git a/app/grid/page.tsx b/app/grid/page.tsx index cb9d59eb..219a2aa8 100644 --- a/app/grid/page.tsx +++ b/app/grid/page.tsx @@ -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 { - 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, }} /> diff --git a/app/page.tsx b/app/page.tsx index 67d33b21..2e2ecafd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 { - 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, }} /> - : + : : ); } diff --git a/app/template-image-tight/route.tsx b/app/template-image-tight/route.tsx index cc3f27e2..d1ee4201 100644 --- a/app/template-image-tight/route.tsx +++ b/app/template-image-tight/route.tsx @@ -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(), diff --git a/app/template-image/route.tsx b/app/template-image/route.tsx index e23edf2f..e920f7d9 100644 --- a/app/template-image/route.tsx +++ b/app/template-image/route.tsx @@ -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(), diff --git a/eslint.config.mjs b/eslint.config.mjs index 47a47718..82cce0f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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': '^_', diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index c6992119..54f2674f 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -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[]) => -
+
{variables.map(variable => )}
; @@ -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')}: - {' '} - - create store - - {' '} - and connect to project - , - )} - {hasCloudflareR2Storage - ? renderSubStatus('checked', 'Cloudflare R2: connected') - : renderSubStatus('optional', <> - {labelForStorage('cloudflare-r2')}: - {' '} - - create/configure bucket - - )} - {hasAwsS3Storage - ? renderSubStatus('checked', 'AWS S3: connected') - : renderSubStatus('optional', <> - {labelForStorage('aws-s3')}: - {' '} - - create/configure bucket - - )} +
+ {hasVercelBlobStorage + ? renderSubStatus('checked', 'Vercel Blob: connected') + : renderSubStatus('optional', <> + {labelForStorage('vercel-blob')}: + {' '} + + create store + + {' '} + and connect to project + , + )} + {hasCloudflareR2Storage + ? renderSubStatus('checked', 'Cloudflare R2: connected') + : renderSubStatus('optional', <> + {labelForStorage('cloudflare-r2')}: + {' '} + + create/configure bucket + + )} + {hasAwsS3Storage + ? renderSubStatus('checked', 'AWS S3: connected') + : renderSubStatus('optional', <> + {labelForStorage('aws-s3')}: + {' '} + + create/configure bucket + + )} +
- {hasAiTextAutoGeneratedFields && - AI_AUTO_GENERATED_FIELDS_ALL.map(field => - - {renderSubStatus( - aiTextAutoGeneratedFields.includes(field) - ? 'checked' - : 'optional', - field, - )} - )} +
+ {hasAiTextAutoGeneratedFields && + AI_AUTO_GENERATED_FIELDS_ALL.map(field => + + {renderSubStatus( + aiTextAutoGeneratedFields.includes(field) + ? 'checked' + : 'optional', + field, + )} + )} +
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', - )} +
+ {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', + )} +
-
+
{categoryVisibility.map((category, index) => {renderSubStatus( @@ -590,6 +601,50 @@ export default function AdminAppConfigurationClient({ {renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])} + } + optional + > + +
+ {SORT_BY_OPTIONS.map(({sortBy, string }) => + + {renderSubStatus( + sortBy === defaultSortBy ? 'checked' : 'optional', + `${string}${sortBy === APP_DEFAULT_SORT_BY + ? ' (default)' + : ''}`, + )} + )} +
+ Change default sort on grid/feed homepages + {renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])} +
+ + 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'])} + + + Set environment variable to {'"1"'} to + show sort control in desktop nav on grid/feed homepages: + {renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])} + +
} @@ -790,15 +845,6 @@ export default function AdminAppConfigurationClient({ {renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}: {renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])} - - Set environment variable to {'"1"'} to prevent - priority order photo field affecting photo order: - {renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])} - { + if (hasLoadedRef.current) { + // After initial load, invalidate cache every time sort changes + invalidateSwr?.(); + } + hasLoadedRef.current = true; + }, [invalidateSwr, sortBy]); + const refHrefFeed = useRef(null); const refHrefGrid = useRef(null); @@ -65,7 +88,7 @@ export default function AppViewSwitcher({ const renderItemFeed = } - 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 = } - 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 />} + {showSortControl && + + } + tooltip={{ + content: isAscending + ? 'View newest first' + : 'View oldest first', + }} + /> + } } diff --git a/src/app/config.ts b/src/app/config.ts index 6bf4507f..d135ae1e 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -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: ( diff --git a/src/app/paths.ts b/src/app/paths.ts index 6eab1d4f..0c62c2b2 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -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'; diff --git a/src/category/index.ts b/src/category/index.ts index 88663f94..d0fa8bd4 100644 --- a/src/category/index.ts +++ b/src/category/index.ts @@ -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'; diff --git a/src/category/useCategoryCounts.ts b/src/category/useCategoryCounts.ts index 1d5a02f1..fd8d0349 100644 --- a/src/category/useCategoryCounts.ts +++ b/src/category/useCategoryCounts.ts @@ -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'; diff --git a/src/components/ChecklistRow.tsx b/src/components/ChecklistRow.tsx index aaaac752..06c98f1b 100644 --- a/src/components/ChecklistRow.tsx +++ b/src/components/ChecklistRow.tsx @@ -40,7 +40,7 @@ export default function ChecklistRow({ {experimental && }
-
+
{children}
} diff --git a/src/components/Switcher.tsx b/src/components/Switcher.tsx index e5898f17..c3172023 100644 --- a/src/components/Switcher.tsx +++ b/src/components/Switcher.tsx @@ -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 (
{children}
diff --git a/src/components/SwitcherItem.tsx b/src/components/SwitcherItem.tsx index b1489205..850bdff8 100644 --- a/src/components/SwitcherItem.tsx +++ b/src/components/SwitcherItem.tsx @@ -61,6 +61,7 @@ export default function SwitcherItem({ href, ref: hrefRef, title, + onClick, className, prefetch, icon: renderIcon(), diff --git a/src/components/icons/IconSort.tsx b/src/components/icons/IconSort.tsx new file mode 100644 index 00000000..89b93b47 --- /dev/null +++ b/src/components/icons/IconSort.tsx @@ -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' + ? + : ; +} diff --git a/src/feed/json.ts b/src/feed/json.ts index bf2d9a19..b9579ea9 100644 --- a/src/feed/json.ts +++ b/src/feed/json.ts @@ -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 diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index 0b2394b3..5d80a1e3 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -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, diff --git a/src/photo/PhotoFeedPage.tsx b/src/photo/PhotoFeedPage.tsx index 5411c494..5345cd5d 100644 --- a/src/photo/PhotoFeedPage.tsx +++ b/src/photo/PhotoFeedPage.tsx @@ -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 (
{photosCount > photos.length && } diff --git a/src/photo/PhotoGridContainer.tsx b/src/photo/PhotoGridContainer.tsx index 1c602fdd..ef0caaf6 100644 --- a/src/photo/PhotoGridContainer.tsx +++ b/src/photo/PhotoGridContainer.tsx @@ -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) { @@ -54,6 +59,8 @@ export default function PhotoGridContainer({ , 'photos'>) { return ( {({ photos, onLastPhotoVisible }) => diff --git a/src/photo/PhotoGridPageClient.tsx b/src/photo/PhotoGridPageClient.tsx index 84e0ef36..40963659 100644 --- a/src/photo/PhotoGridPageClient.tsx +++ b/src/photo/PhotoGridPageClient.tsx @@ -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 & { photos: Photo[] photosCount: number + sortBy: SortBy + sortWithPriority: boolean }) { const ref = useRef(null); @@ -35,6 +40,8 @@ export default function PhotoGridPageClient({ cacheKey={`page-${PATH_GRID_INFERRED}`} photos={photos} count={photosCount} + sortBy={sortBy} + sortWithPriority={sortWithPriority} sidebar={ {({ photos, onLastPhotoVisible, revalidatePhoto }) => diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index 9893ffc1..6ab15b4d 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -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'; } }; diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 717f028c..d52b9044 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -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 ( } } } 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(); diff --git a/src/photo/db/sort-path.ts b/src/photo/db/sort-path.ts new file mode 100644 index 00000000..1ae9c38e --- /dev/null +++ b/src/photo/db/sort-path.ts @@ -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> => { + 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}`, + }; +}; diff --git a/src/photo/db/sort.ts b/src/photo/db/sort.ts new file mode 100644 index 00000000..a61f00f5 --- /dev/null +++ b/src/photo/db/sort.ts @@ -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'; diff --git a/src/photo/form/server.ts b/src/photo/form/server.ts index 6d4158b7..5ea2a7e3 100644 --- a/src/photo/form/server.ts +++ b/src/photo/form/server.ts @@ -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'; diff --git a/src/recents/RecentsOGTile.tsx b/src/recents/RecentsOGTile.tsx index 4bb3fbdd..d29b98b7 100644 --- a/src/recents/RecentsOGTile.tsx +++ b/src/recents/RecentsOGTile.tsx @@ -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({ diff --git a/src/recipe/index.ts b/src/recipe/index.ts index 08e5ed9d..af02885a 100644 --- a/src/recipe/index.ts +++ b/src/recipe/index.ts @@ -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, diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index 5002199a..8b523893 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -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'; diff --git a/src/utility/useElementHeight.ts b/src/utility/useElementHeight.ts index 3be2f8a7..c647bace 100644 --- a/src/utility/useElementHeight.ts +++ b/src/utility/useElementHeight.ts @@ -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, diff --git a/src/years/YearOGTile.tsx b/src/years/YearOGTile.tsx index 45fff7e2..bad9b02e 100644 --- a/src/years/YearOGTile.tsx +++ b/src/years/YearOGTile.tsx @@ -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({