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:
parent
c189e567b8
commit
d7fbc8bd68
12
README.md
12
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
|
||||
|
||||
53
app/feed/[sortType]/[sortOrder]/page.tsx
Normal file
53
app/feed/[sortType]/[sortOrder]/page.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
);
|
||||
}
|
||||
|
||||
59
app/grid/[sortType]/[sortOrder]/page.tsx
Normal file
59
app/grid/[sortType]/[sortOrder]/page.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
20
app/page.tsx
20
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<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 />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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': '^_',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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} />}
|
||||
|
||||
@ -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: (
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
</>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -61,6 +61,7 @@ export default function SwitcherItem({
|
||||
href,
|
||||
ref: hrefRef,
|
||||
title,
|
||||
onClick,
|
||||
className,
|
||||
prefetch,
|
||||
icon: renderIcon(),
|
||||
|
||||
11
src/components/icons/IconSort.tsx
Normal file
11
src/components/icons/IconSort.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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
128
src/photo/db/sort-path.ts
Normal 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
43
src/photo/db/sort.ts
Normal 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';
|
||||
@ -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';
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user