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_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content
|
||||||
- `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos
|
- `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos
|
||||||
|
|
||||||
|
#### Sorting
|
||||||
|
- `NEXT_PUBLIC_DEFAULT_SORT`
|
||||||
|
- Sets default sort on grid/feed homepages
|
||||||
|
- Accepted values:
|
||||||
|
- `taken-at` (default)
|
||||||
|
- `taken-at-oldest-first`
|
||||||
|
- `uploaded-at`
|
||||||
|
- `uploaded-at-oldest-first`
|
||||||
|
- `NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1` takes priority field into account when sorting photos (⚠️ enabling may have performance consequences)
|
||||||
|
- `NEXT_PUBLIC_SHOW_SORT_CONTROL = 1` shows sort control in desktop nav on grid/feed homepages
|
||||||
|
|
||||||
#### Display
|
#### Display
|
||||||
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
|
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
|
||||||
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
|
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
|
||||||
@ -165,7 +176,6 @@ Application behavior can be changed by configuring the following environment var
|
|||||||
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
|
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
|
||||||
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
|
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
|
||||||
- `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml`
|
- `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml`
|
||||||
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
|
|
||||||
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
|
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
|
||||||
|
|
||||||
## Alternate storage providers
|
## Alternate storage providers
|
||||||
|
|||||||
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 { getPhotos } from '@/photo/db/query';
|
||||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
|
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||||
|
import { GetPhotosOptions } from '@/photo/db';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache(() => getPhotos({
|
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||||
|
...options,
|
||||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const photos = await getPhotosCached()
|
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
return generateOgImageMetaForPhotos(photos);
|
return generateOgImageMetaForPhotos(photos);
|
||||||
}
|
}
|
||||||
@ -27,16 +30,20 @@ export default async function FeedPage() {
|
|||||||
photos,
|
photos,
|
||||||
photosCount,
|
photosCount,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPhotosCached()
|
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
getPhotosMetaCached()
|
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.then(({ count }) => count)
|
.then(({ count }) => count)
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
photos.length > 0
|
photos.length > 0
|
||||||
? <PhotoFeedPage {...{ photos, photosCount }} />
|
? <PhotoFeedPage {...{
|
||||||
|
photos,
|
||||||
|
photosCount,
|
||||||
|
...USER_DEFAULT_SORT_OPTIONS,
|
||||||
|
}} />
|
||||||
: <PhotosEmptyState />
|
: <PhotosEmptyState />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 PhotoGridPage from '@/photo/PhotoGridPage';
|
||||||
import { getDataForCategoriesCached } from '@/category/cache';
|
import { getDataForCategoriesCached } from '@/category/cache';
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
|
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||||
|
import { GetPhotosOptions } from '@/photo/db';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache(() => getPhotos({
|
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||||
|
...options,
|
||||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const photos = await getPhotosCached()
|
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
return generateOgImageMetaForPhotos(photos);
|
return generateOgImageMetaForPhotos(photos);
|
||||||
}
|
}
|
||||||
@ -28,9 +32,9 @@ export default async function GridPage() {
|
|||||||
photosCount,
|
photosCount,
|
||||||
categories,
|
categories,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPhotosCached()
|
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
getPhotosMetaCached()
|
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.then(({ count }) => count)
|
.then(({ count }) => count)
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
getDataForCategoriesCached(),
|
getDataForCategoriesCached(),
|
||||||
@ -42,6 +46,7 @@ export default async function GridPage() {
|
|||||||
{...{
|
{...{
|
||||||
photos,
|
photos,
|
||||||
photosCount,
|
photosCount,
|
||||||
|
...USER_DEFAULT_SORT_OPTIONS,
|
||||||
...categories,
|
...categories,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
20
app/page.tsx
20
app/page.tsx
@ -7,23 +7,26 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
|||||||
import { Metadata } from 'next/types';
|
import { Metadata } from 'next/types';
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { getPhotos } from '@/photo/db/query';
|
import { getPhotos } from '@/photo/db/query';
|
||||||
import { GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
import { GRID_HOMEPAGE_ENABLED, USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||||
import { NULL_CATEGORY_DATA } from '@/category/data';
|
import { NULL_CATEGORY_DATA } from '@/category/data';
|
||||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||||
import { getDataForCategoriesCached } from '@/category/cache';
|
import { getDataForCategoriesCached } from '@/category/cache';
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
|
import { GetPhotosOptions } from '@/photo/db';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache(() => getPhotos({
|
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||||
|
...options,
|
||||||
limit: GRID_HOMEPAGE_ENABLED
|
limit: GRID_HOMEPAGE_ENABLED
|
||||||
? INFINITE_SCROLL_GRID_INITIAL
|
? INFINITE_SCROLL_GRID_INITIAL
|
||||||
: INFINITE_SCROLL_FEED_INITIAL,
|
: INFINITE_SCROLL_FEED_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const photos = await getPhotosCached()
|
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
return generateOgImageMetaForPhotos(photos);
|
return generateOgImageMetaForPhotos(photos);
|
||||||
}
|
}
|
||||||
@ -34,9 +37,9 @@ export default async function HomePage() {
|
|||||||
photosCount,
|
photosCount,
|
||||||
categories,
|
categories,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPhotosCached()
|
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
getPhotosMetaCached()
|
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
|
||||||
.then(({ count }) => count)
|
.then(({ count }) => count)
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
GRID_HOMEPAGE_ENABLED
|
GRID_HOMEPAGE_ENABLED
|
||||||
@ -51,10 +54,15 @@ export default async function HomePage() {
|
|||||||
{...{
|
{...{
|
||||||
photos,
|
photos,
|
||||||
photosCount,
|
photosCount,
|
||||||
|
...USER_DEFAULT_SORT_OPTIONS,
|
||||||
...categories,
|
...categories,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
: <PhotoFeedPage {...{ photos, photosCount }} />
|
: <PhotoFeedPage {...{
|
||||||
|
photos,
|
||||||
|
photosCount,
|
||||||
|
...USER_DEFAULT_SORT_OPTIONS,
|
||||||
|
}} />
|
||||||
: <PhotosEmptyState />
|
: <PhotosEmptyState />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function GET() {
|
|||||||
headers,
|
headers,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPhotosCached({
|
getPhotosCached({
|
||||||
sortBy: 'priority',
|
sortWithPriority: true,
|
||||||
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
|
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
|
||||||
}).catch(() => []),
|
}).catch(() => []),
|
||||||
getIBMPlexMono(),
|
getIBMPlexMono(),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function GET() {
|
|||||||
headers,
|
headers,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPhotosCached({
|
getPhotosCached({
|
||||||
sortBy: 'priority',
|
sortWithPriority: true,
|
||||||
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE,
|
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE,
|
||||||
}).catch(() => []),
|
}).catch(() => []),
|
||||||
getIBMPlexMono(),
|
getIBMPlexMono(),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const eslintConfig = [
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-require-imports': 'off',
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
'no-unused-expressions': ['warn'],
|
'no-unused-expressions': ['warn'],
|
||||||
|
'no-duplicate-imports': ['warn'],
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn', {
|
'warn', {
|
||||||
'argsIgnorePattern': '^_',
|
'argsIgnorePattern': '^_',
|
||||||
|
|||||||
@ -12,12 +12,11 @@ import {
|
|||||||
BiLockAlt,
|
BiLockAlt,
|
||||||
BiPencil,
|
BiPencil,
|
||||||
} from 'react-icons/bi';
|
} from 'react-icons/bi';
|
||||||
import { HiOutlineCog } from 'react-icons/hi';
|
import { HiOutlineCog, HiSparkles } from 'react-icons/hi';
|
||||||
import ChecklistGroup from '@/components/ChecklistGroup';
|
import ChecklistGroup from '@/components/ChecklistGroup';
|
||||||
import { AppConfiguration } from '../app/config';
|
import { AppConfiguration } from '../app/config';
|
||||||
import StatusIcon from '@/components/StatusIcon';
|
import StatusIcon from '@/components/StatusIcon';
|
||||||
import { labelForStorage } from '@/platforms/storage';
|
import { labelForStorage } from '@/platforms/storage';
|
||||||
import { HiSparkles } from 'react-icons/hi';
|
|
||||||
import { testConnectionsAction } from '@/admin/actions';
|
import { testConnectionsAction } from '@/admin/actions';
|
||||||
import ErrorNote from '@/components/ErrorNote';
|
import ErrorNote from '@/components/ErrorNote';
|
||||||
import { RiSpeedMiniLine } from 'react-icons/ri';
|
import { RiSpeedMiniLine } from 'react-icons/ri';
|
||||||
@ -34,6 +33,8 @@ import clsx from 'clsx/lite';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
|
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
|
||||||
import { FaRegFolderClosed } from 'react-icons/fa6';
|
import { FaRegFolderClosed } from 'react-icons/fa6';
|
||||||
|
import IconSort from '@/components/icons/IconSort';
|
||||||
|
import { APP_DEFAULT_SORT_BY, SORT_BY_OPTIONS } from '@/photo/db/sort';
|
||||||
|
|
||||||
export default function AdminAppConfigurationClient({
|
export default function AdminAppConfigurationClient({
|
||||||
// Storage
|
// Storage
|
||||||
@ -85,6 +86,11 @@ export default function AdminAppConfigurationClient({
|
|||||||
hasCategoryVisibility,
|
hasCategoryVisibility,
|
||||||
collapseSidebarCategories,
|
collapseSidebarCategories,
|
||||||
hideTagsWithOnePhoto,
|
hideTagsWithOnePhoto,
|
||||||
|
// Sort
|
||||||
|
hasDefaultSortBy,
|
||||||
|
defaultSortBy,
|
||||||
|
isSortWithPriority,
|
||||||
|
showSortControl,
|
||||||
// Display
|
// Display
|
||||||
showKeyboardShortcutTooltips,
|
showKeyboardShortcutTooltips,
|
||||||
showExifInfo,
|
showExifInfo,
|
||||||
@ -110,7 +116,6 @@ export default function AdminAppConfigurationClient({
|
|||||||
isGeoPrivacyEnabled,
|
isGeoPrivacyEnabled,
|
||||||
arePublicDownloadsEnabled,
|
arePublicDownloadsEnabled,
|
||||||
areSiteFeedsEnabled,
|
areSiteFeedsEnabled,
|
||||||
isPriorityOrderEnabled,
|
|
||||||
isOgTextBottomAligned,
|
isOgTextBottomAligned,
|
||||||
// Internal
|
// Internal
|
||||||
areInternalToolsEnabled,
|
areInternalToolsEnabled,
|
||||||
@ -140,7 +145,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const renderEnvVars = (variables: string[]) =>
|
const renderEnvVars = (variables: string[]) =>
|
||||||
<div className="pt-1 flex flex-col gap-1">
|
<div className="pt-1 flex flex-col gap-0.5">
|
||||||
{variables.map(variable =>
|
{variables.map(variable =>
|
||||||
<EnvVar key={variable} variable={variable} />)}
|
<EnvVar key={variable} variable={variable} />)}
|
||||||
</div>;
|
</div>;
|
||||||
@ -244,47 +249,49 @@ export default function AdminAppConfigurationClient({
|
|||||||
{storageError && renderError({
|
{storageError && renderError({
|
||||||
connection: { provider: 'Storage', error: storageError},
|
connection: { provider: 'Storage', error: storageError},
|
||||||
})}
|
})}
|
||||||
{hasVercelBlobStorage
|
<div>
|
||||||
? renderSubStatus('checked', 'Vercel Blob: connected')
|
{hasVercelBlobStorage
|
||||||
: renderSubStatus('optional', <>
|
? renderSubStatus('checked', 'Vercel Blob: connected')
|
||||||
{labelForStorage('vercel-blob')}:
|
: renderSubStatus('optional', <>
|
||||||
{' '}
|
{labelForStorage('vercel-blob')}:
|
||||||
<AdminLink
|
{' '}
|
||||||
// eslint-disable-next-line max-len
|
<AdminLink
|
||||||
href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store"
|
// eslint-disable-next-line max-len
|
||||||
externalIcon
|
href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store"
|
||||||
>
|
externalIcon
|
||||||
create store
|
>
|
||||||
</AdminLink>
|
create store
|
||||||
{' '}
|
</AdminLink>
|
||||||
and connect to project
|
{' '}
|
||||||
</>,
|
and connect to project
|
||||||
)}
|
</>,
|
||||||
{hasCloudflareR2Storage
|
)}
|
||||||
? renderSubStatus('checked', 'Cloudflare R2: connected')
|
{hasCloudflareR2Storage
|
||||||
: renderSubStatus('optional', <>
|
? renderSubStatus('checked', 'Cloudflare R2: connected')
|
||||||
{labelForStorage('cloudflare-r2')}:
|
: renderSubStatus('optional', <>
|
||||||
{' '}
|
{labelForStorage('cloudflare-r2')}:
|
||||||
<AdminLink
|
{' '}
|
||||||
// eslint-disable-next-line max-len
|
<AdminLink
|
||||||
href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2"
|
// eslint-disable-next-line max-len
|
||||||
externalIcon
|
href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2"
|
||||||
>
|
externalIcon
|
||||||
create/configure bucket
|
>
|
||||||
</AdminLink>
|
create/configure bucket
|
||||||
</>)}
|
</AdminLink>
|
||||||
{hasAwsS3Storage
|
</>)}
|
||||||
? renderSubStatus('checked', 'AWS S3: connected')
|
{hasAwsS3Storage
|
||||||
: renderSubStatus('optional', <>
|
? renderSubStatus('checked', 'AWS S3: connected')
|
||||||
{labelForStorage('aws-s3')}:
|
: renderSubStatus('optional', <>
|
||||||
{' '}
|
{labelForStorage('aws-s3')}:
|
||||||
<AdminLink
|
{' '}
|
||||||
href="https://github.com/sambecker/exif-photo-blog#aws-s3"
|
<AdminLink
|
||||||
externalIcon
|
href="https://github.com/sambecker/exif-photo-blog#aws-s3"
|
||||||
>
|
externalIcon
|
||||||
create/configure bucket
|
>
|
||||||
</AdminLink>
|
create/configure bucket
|
||||||
</>)}
|
</AdminLink>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</ChecklistGroup>
|
</ChecklistGroup>
|
||||||
<ChecklistGroup
|
<ChecklistGroup
|
||||||
@ -428,16 +435,18 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={hasAiTextAutoGeneratedFields}
|
status={hasAiTextAutoGeneratedFields}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
{hasAiTextAutoGeneratedFields &&
|
<div>
|
||||||
AI_AUTO_GENERATED_FIELDS_ALL.map(field =>
|
{hasAiTextAutoGeneratedFields &&
|
||||||
<Fragment key={field}>
|
AI_AUTO_GENERATED_FIELDS_ALL.map(field =>
|
||||||
{renderSubStatus(
|
<Fragment key={field}>
|
||||||
aiTextAutoGeneratedFields.includes(field)
|
{renderSubStatus(
|
||||||
? 'checked'
|
aiTextAutoGeneratedFields.includes(field)
|
||||||
: 'optional',
|
? 'checked'
|
||||||
field,
|
: 'optional',
|
||||||
)}
|
field,
|
||||||
</Fragment>)}
|
)}
|
||||||
|
</Fragment>)}
|
||||||
|
</div>
|
||||||
Comma-separated fields to auto-generate when
|
Comma-separated fields to auto-generate when
|
||||||
uploading photos. Accepted values: title, caption,
|
uploading photos. Accepted values: title, caption,
|
||||||
tags, description, all, or none
|
tags, description, all, or none
|
||||||
@ -483,23 +492,25 @@ export default function AdminAppConfigurationClient({
|
|||||||
Set environment variable to {'"1"'} to make site more responsive
|
Set environment variable to {'"1"'} to make site more responsive
|
||||||
by enabling static optimization
|
by enabling static optimization
|
||||||
(i.e., rendering pages and images at build time):
|
(i.e., rendering pages and images at build time):
|
||||||
{renderSubStatusWithEnvVar(
|
<div>
|
||||||
arePhotosStaticallyOptimized ? 'checked' : 'optional',
|
{renderSubStatusWithEnvVar(
|
||||||
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
|
arePhotosStaticallyOptimized ? 'checked' : 'optional',
|
||||||
)}
|
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
|
||||||
{renderSubStatusWithEnvVar(
|
)}
|
||||||
arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional',
|
{renderSubStatusWithEnvVar(
|
||||||
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES',
|
arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional',
|
||||||
)}
|
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES',
|
||||||
{renderSubStatusWithEnvVar(
|
)}
|
||||||
arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional',
|
{renderSubStatusWithEnvVar(
|
||||||
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES',
|
arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional',
|
||||||
)}
|
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES',
|
||||||
{renderSubStatusWithEnvVar(
|
)}
|
||||||
// eslint-disable-next-line max-len
|
{renderSubStatusWithEnvVar(
|
||||||
arePhotoCategoryOgImagesStaticallyOptimized ? 'checked' : 'optional',
|
// eslint-disable-next-line max-len
|
||||||
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES',
|
arePhotoCategoryOgImagesStaticallyOptimized ? 'checked' : 'optional',
|
||||||
)}
|
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Preserve original uploads"
|
title="Preserve original uploads"
|
||||||
@ -541,7 +552,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={hasCategoryVisibility}
|
status={hasCategoryVisibility}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
<div className="my-1">
|
<div>
|
||||||
{categoryVisibility.map((category, index) =>
|
{categoryVisibility.map((category, index) =>
|
||||||
<Fragment key={category}>
|
<Fragment key={category}>
|
||||||
{renderSubStatus(
|
{renderSubStatus(
|
||||||
@ -590,6 +601,50 @@ export default function AdminAppConfigurationClient({
|
|||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</ChecklistGroup>
|
</ChecklistGroup>
|
||||||
|
<ChecklistGroup
|
||||||
|
title="Sorting"
|
||||||
|
icon={<IconSort size={18} className="translate-y-[1px]" />}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
<ChecklistRow
|
||||||
|
title="Default sort"
|
||||||
|
status={hasDefaultSortBy}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{SORT_BY_OPTIONS.map(({sortBy, string }) =>
|
||||||
|
<Fragment key={ sortBy }>
|
||||||
|
{renderSubStatus(
|
||||||
|
sortBy === defaultSortBy ? 'checked' : 'optional',
|
||||||
|
`${string}${sortBy === APP_DEFAULT_SORT_BY
|
||||||
|
? ' (default)'
|
||||||
|
: ''}`,
|
||||||
|
)}
|
||||||
|
</Fragment>)}
|
||||||
|
</div>
|
||||||
|
Change default sort on grid/feed homepages
|
||||||
|
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
|
||||||
|
</ChecklistRow>
|
||||||
|
<ChecklistRow
|
||||||
|
title="Priority-based sorting"
|
||||||
|
status={isSortWithPriority}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
Set environment variable to {'"1"'} to take priority field
|
||||||
|
into account when sorting photos (enabling may have
|
||||||
|
performance consequences):
|
||||||
|
{renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])}
|
||||||
|
</ChecklistRow>
|
||||||
|
<ChecklistRow
|
||||||
|
title="Show sort control"
|
||||||
|
status={showSortControl}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
Set environment variable to {'"1"'} to
|
||||||
|
show sort control in desktop nav on grid/feed homepages:
|
||||||
|
{renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
|
||||||
|
</ChecklistRow>
|
||||||
|
</ChecklistGroup>
|
||||||
<ChecklistGroup
|
<ChecklistGroup
|
||||||
title="Display"
|
title="Display"
|
||||||
icon={<BiHide size={18} />}
|
icon={<BiHide size={18} />}
|
||||||
@ -790,15 +845,6 @@ export default function AdminAppConfigurationClient({
|
|||||||
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
|
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
|
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
|
||||||
title="Priority order"
|
|
||||||
status={isPriorityOrderEnabled}
|
|
||||||
optional
|
|
||||||
>
|
|
||||||
Set environment variable to {'"1"'} to prevent
|
|
||||||
priority order photo field affecting photo order:
|
|
||||||
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
|
|
||||||
</ChecklistRow>
|
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Legacy OG text alignment"
|
title="Legacy OG text alignment"
|
||||||
status={isOgTextBottomAligned}
|
status={isOgTextBottomAligned}
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import {
|
|||||||
getUniqueLenses,
|
getUniqueLenses,
|
||||||
getUniqueRecipes,
|
getUniqueRecipes,
|
||||||
getUniqueTags,
|
getUniqueTags,
|
||||||
|
getPhotosInNeedOfSyncCount,
|
||||||
} from '@/photo/db/query';
|
} from '@/photo/db/query';
|
||||||
import AdminAppInsightsClient from './AdminAppInsightsClient';
|
import AdminAppInsightsClient from './AdminAppInsightsClient';
|
||||||
import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
|
import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
|
||||||
import { getPhotosInNeedOfSyncCount } from '@/photo/db/query';
|
|
||||||
|
|
||||||
export default async function AdminAppInsights() {
|
export default async function AdminAppInsights() {
|
||||||
const [
|
const [
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import SwitcherItem from '@/components/SwitcherItem';
|
|||||||
import IconFeed from '@/components/icons/IconFeed';
|
import IconFeed from '@/components/icons/IconFeed';
|
||||||
import IconGrid from '@/components/icons/IconGrid';
|
import IconGrid from '@/components/icons/IconGrid';
|
||||||
import {
|
import {
|
||||||
|
doesPathOfferSort,
|
||||||
PATH_FEED_INFERRED,
|
PATH_FEED_INFERRED,
|
||||||
PATH_GRID_INFERRED,
|
PATH_GRID_INFERRED,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
@ -11,15 +12,18 @@ import { useAppState } from '@/state/AppState';
|
|||||||
import {
|
import {
|
||||||
GRID_HOMEPAGE_ENABLED,
|
GRID_HOMEPAGE_ENABLED,
|
||||||
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
|
SHOW_SORT_CONTROL,
|
||||||
} from './config';
|
} from './config';
|
||||||
import AdminAppMenu from '@/admin/AdminAppMenu';
|
import AdminAppMenu from '@/admin/AdminAppMenu';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import useKeydownHandler from '@/utility/useKeydownHandler';
|
import useKeydownHandler from '@/utility/useKeydownHandler';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { KEY_COMMANDS } from '@/photo/key-commands';
|
import { KEY_COMMANDS } from '@/photo/key-commands';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import IconSort from '@/components/icons/IconSort';
|
||||||
|
import { getSortConfigFromPath } from '@/photo/db/sort-path';
|
||||||
|
|
||||||
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
|
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
|
||||||
|
|
||||||
@ -38,8 +42,27 @@ export default function AppViewSwitcher({
|
|||||||
isUserSignedIn,
|
isUserSignedIn,
|
||||||
isUserSignedInEager,
|
isUserSignedInEager,
|
||||||
setIsCommandKOpen,
|
setIsCommandKOpen,
|
||||||
|
invalidateSwr,
|
||||||
} = useAppState();
|
} = useAppState();
|
||||||
|
|
||||||
|
const showSortControl = SHOW_SORT_CONTROL && doesPathOfferSort(pathname);
|
||||||
|
const {
|
||||||
|
sortBy,
|
||||||
|
isAscending,
|
||||||
|
pathGrid,
|
||||||
|
pathFeed,
|
||||||
|
pathSort,
|
||||||
|
} = getSortConfigFromPath(pathname);
|
||||||
|
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasLoadedRef.current) {
|
||||||
|
// After initial load, invalidate cache every time sort changes
|
||||||
|
invalidateSwr?.();
|
||||||
|
}
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
}, [invalidateSwr, sortBy]);
|
||||||
|
|
||||||
const refHrefFeed = useRef<HTMLAnchorElement>(null);
|
const refHrefFeed = useRef<HTMLAnchorElement>(null);
|
||||||
const refHrefGrid = useRef<HTMLAnchorElement>(null);
|
const refHrefGrid = useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
@ -65,7 +88,7 @@ export default function AppViewSwitcher({
|
|||||||
const renderItemFeed =
|
const renderItemFeed =
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<IconFeed includeTitle={false} />}
|
icon={<IconFeed includeTitle={false} />}
|
||||||
href={PATH_FEED_INFERRED}
|
href={pathFeed}
|
||||||
hrefRef={refHrefFeed}
|
hrefRef={refHrefFeed}
|
||||||
active={currentSelection === 'feed'}
|
active={currentSelection === 'feed'}
|
||||||
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||||
@ -78,7 +101,7 @@ export default function AppViewSwitcher({
|
|||||||
const renderItemGrid =
|
const renderItemGrid =
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<IconGrid includeTitle={false} />}
|
icon={<IconGrid includeTitle={false} />}
|
||||||
href={PATH_GRID_INFERRED}
|
href={pathGrid}
|
||||||
hrefRef={refHrefGrid}
|
hrefRef={refHrefGrid}
|
||||||
active={currentSelection === 'grid'}
|
active={currentSelection === 'grid'}
|
||||||
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||||
@ -126,6 +149,21 @@ export default function AppViewSwitcher({
|
|||||||
noPadding
|
noPadding
|
||||||
/>}
|
/>}
|
||||||
</Switcher>
|
</Switcher>
|
||||||
|
{showSortControl &&
|
||||||
|
<Switcher className="max-sm:hidden">
|
||||||
|
<SwitcherItem
|
||||||
|
href={pathSort}
|
||||||
|
icon={<IconSort
|
||||||
|
sort={isAscending ? 'asc' : 'desc'}
|
||||||
|
className="translate-x-[0.5px] translate-y-[1px]"
|
||||||
|
/>}
|
||||||
|
tooltip={{
|
||||||
|
content: isAscending
|
||||||
|
? 'View newest first'
|
||||||
|
: 'View oldest first',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Switcher>}
|
||||||
<Switcher type="borderless">
|
<Switcher type="borderless">
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<IconSearch includeTitle={false} />}
|
icon={<IconSearch includeTitle={false} />}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
makeUrlAbsolute,
|
makeUrlAbsolute,
|
||||||
shortenUrl,
|
shortenUrl,
|
||||||
} from '@/utility/url';
|
} from '@/utility/url';
|
||||||
|
import { getSortByFromString } from '@/photo/db/sort';
|
||||||
|
|
||||||
// HARD-CODED GLOBAL CONFIGURATION
|
// HARD-CODED GLOBAL CONFIGURATION
|
||||||
|
|
||||||
@ -267,6 +268,19 @@ export const COLLAPSE_SIDEBAR_CATEGORIES =
|
|||||||
export const HIDE_TAGS_WITH_ONE_PHOTO =
|
export const HIDE_TAGS_WITH_ONE_PHOTO =
|
||||||
process.env.NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO === '1';
|
process.env.NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO === '1';
|
||||||
|
|
||||||
|
// SORT
|
||||||
|
|
||||||
|
export const USER_DEFAULT_SORT_BY =
|
||||||
|
getSortByFromString(process.env.NEXT_PUBLIC_DEFAULT_SORT);
|
||||||
|
export const USER_DEFAULT_SORT_WITH_PRIORITY =
|
||||||
|
process.env.NEXT_PUBLIC_PRIORITY_BASED_SORTING === '1';
|
||||||
|
export const USER_DEFAULT_SORT_OPTIONS = {
|
||||||
|
sortBy: USER_DEFAULT_SORT_BY,
|
||||||
|
sortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
|
||||||
|
};
|
||||||
|
export const SHOW_SORT_CONTROL =
|
||||||
|
process.env.NEXT_PUBLIC_SHOW_SORT_CONTROL === '1';
|
||||||
|
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
|
|
||||||
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
||||||
@ -321,8 +335,6 @@ export const ALLOW_PUBLIC_DOWNLOADS =
|
|||||||
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
|
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
|
||||||
export const SITE_FEEDS_ENABLED =
|
export const SITE_FEEDS_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_SITE_FEEDS === '1';
|
process.env.NEXT_PUBLIC_SITE_FEEDS === '1';
|
||||||
export const PRIORITY_ORDER_ENABLED =
|
|
||||||
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
|
|
||||||
export const OG_TEXT_BOTTOM_ALIGNMENT =
|
export const OG_TEXT_BOTTOM_ALIGNMENT =
|
||||||
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
|
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
|
||||||
|
|
||||||
@ -405,6 +417,11 @@ export const APP_CONFIGURATION = {
|
|||||||
categoryVisibility: CATEGORY_VISIBILITY,
|
categoryVisibility: CATEGORY_VISIBILITY,
|
||||||
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
||||||
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
|
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
|
||||||
|
// Sort
|
||||||
|
hasDefaultSortBy: Boolean(process.env.NEXT_PUBLIC_DEFAULT_SORT),
|
||||||
|
defaultSortBy: USER_DEFAULT_SORT_BY,
|
||||||
|
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
|
||||||
|
showSortControl: SHOW_SORT_CONTROL,
|
||||||
// Display
|
// Display
|
||||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
showExifInfo: SHOW_EXIF_DATA,
|
showExifInfo: SHOW_EXIF_DATA,
|
||||||
@ -433,7 +450,6 @@ export const APP_CONFIGURATION = {
|
|||||||
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
|
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
|
||||||
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
|
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
|
||||||
areSiteFeedsEnabled: SITE_FEEDS_ENABLED,
|
areSiteFeedsEnabled: SITE_FEEDS_ENABLED,
|
||||||
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
|
|
||||||
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
|
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
|
||||||
// Internal
|
// Internal
|
||||||
areInternalToolsEnabled: (
|
areInternalToolsEnabled: (
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { parameterize } from '@/utility/string';
|
|||||||
import { TAG_HIDDEN } from '@/tag';
|
import { TAG_HIDDEN } from '@/tag';
|
||||||
import { Lens } from '@/lens';
|
import { Lens } from '@/lens';
|
||||||
|
|
||||||
// Core paths
|
// Core
|
||||||
export const PATH_ROOT = '/';
|
export const PATH_ROOT = '/';
|
||||||
export const PATH_GRID = '/grid';
|
export const PATH_GRID = '/grid';
|
||||||
export const PATH_FEED = '/feed';
|
export const PATH_FEED = '/feed';
|
||||||
@ -15,18 +15,29 @@ export const PATH_API = '/api';
|
|||||||
export const PATH_SIGN_IN = '/sign-in';
|
export const PATH_SIGN_IN = '/sign-in';
|
||||||
export const PATH_OG = '/og';
|
export const PATH_OG = '/og';
|
||||||
|
|
||||||
// Feeds
|
// Core: inferred
|
||||||
export const PATH_FEED_JSON = '/feed.json';
|
|
||||||
export const PATH_RSS_XML = '/rss.xml';
|
|
||||||
|
|
||||||
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
|
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
|
||||||
? PATH_ROOT
|
? PATH_ROOT
|
||||||
: PATH_GRID;
|
: PATH_GRID;
|
||||||
|
|
||||||
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
|
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
|
||||||
? PATH_FEED
|
? PATH_FEED
|
||||||
: PATH_ROOT;
|
: PATH_ROOT;
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
export const PARAM_SORT_TYPE_TAKEN_AT = 'taken-at';
|
||||||
|
export const PARAM_SORT_TYPE_UPLOADED_AT = 'uploaded-at';
|
||||||
|
export const PARAM_SORT_ORDER_NEWEST = 'newest-first';
|
||||||
|
export const PARAM_SORT_ORDER_OLDEST = 'oldest-first';
|
||||||
|
export const doesPathOfferSort = (pathname: string) =>
|
||||||
|
pathname === PATH_ROOT ||
|
||||||
|
pathname.startsWith(PATH_GRID) ||
|
||||||
|
pathname.startsWith(PATH_FEED);
|
||||||
|
|
||||||
|
// Feeds
|
||||||
|
export const PATH_SITEMAP = '/sitemap.xml';
|
||||||
|
export const PATH_RSS_XML = '/rss.xml';
|
||||||
|
export const PATH_FEED_JSON = '/feed.json';
|
||||||
|
|
||||||
// Path prefixes
|
// Path prefixes
|
||||||
export const PREFIX_PHOTO = '/p';
|
export const PREFIX_PHOTO = '/p';
|
||||||
export const PREFIX_CAMERA = '/shot-on';
|
export const PREFIX_CAMERA = '/shot-on';
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Photo } from '../photo';
|
import { Photo, PhotoDateRange } from '../photo';
|
||||||
import { Camera, Cameras } from '@/camera';
|
import { Camera, Cameras } from '@/camera';
|
||||||
import { PhotoDateRange } from '../photo';
|
|
||||||
import { Films } from '@/film';
|
import { Films } from '@/film';
|
||||||
import { Lens, Lenses } from '@/lens';
|
import { Lens, Lenses } from '@/lens';
|
||||||
import { Tags } from '@/tag';
|
import { Tags } from '@/tag';
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { createCameraKey } from '@/camera';
|
import { createCameraKey, Camera } from '@/camera';
|
||||||
import { createLensKey } from '@/lens';
|
import { createLensKey, Lens } from '@/lens';
|
||||||
import { Camera } from '@/camera';
|
|
||||||
import { Lens } from '@/lens';
|
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export default function ChecklistRow({
|
|||||||
{experimental &&
|
{experimental &&
|
||||||
<ExperimentalBadge className="translate-y-[0.5px]" />}
|
<ExperimentalBadge className="translate-y-[0.5px]" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="leading-relaxed text-medium">
|
<div className="leading-relaxed text-medium space-y-1.5">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|||||||
@ -4,9 +4,11 @@ import { clsx } from 'clsx/lite';
|
|||||||
export default function Switcher({
|
export default function Switcher({
|
||||||
children,
|
children,
|
||||||
type = 'regular',
|
type = 'regular',
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
type?: 'regular' | 'borderless'
|
type?: 'regular' | 'borderless'
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
@ -17,6 +19,7 @@ export default function Switcher({
|
|||||||
'divide-medium',
|
'divide-medium',
|
||||||
type === 'regular' &&
|
type === 'regular' &&
|
||||||
'outline-medium shadow-[0_2px_4px_rgba(0,0,0,0.07)]',
|
'outline-medium shadow-[0_2px_4px_rgba(0,0,0,0.07)]',
|
||||||
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -61,6 +61,7 @@ export default function SwitcherItem({
|
|||||||
href,
|
href,
|
||||||
ref: hrefRef,
|
ref: hrefRef,
|
||||||
title,
|
title,
|
||||||
|
onClick,
|
||||||
className,
|
className,
|
||||||
prefetch,
|
prefetch,
|
||||||
icon: renderIcon(),
|
icon: renderIcon(),
|
||||||
|
|||||||
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 '.';
|
} from '.';
|
||||||
import { formatDateFromPostgresString } from '@/utility/date';
|
import { formatDateFromPostgresString } from '@/utility/date';
|
||||||
import { Photo } from '@/photo';
|
import { Photo } from '@/photo';
|
||||||
import { BASE_URL, META_DESCRIPTION } from '@/app/config';
|
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
|
||||||
import { META_TITLE } from '@/app/config';
|
|
||||||
|
|
||||||
interface FeedPhotoJson {
|
interface FeedPhotoJson {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@ -15,9 +15,9 @@ import { Photo } from '.';
|
|||||||
import { PhotoSetCategory } from '../category';
|
import { PhotoSetCategory } from '../category';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { GetPhotosOptions } from './db';
|
|
||||||
import useVisible from '@/utility/useVisible';
|
import useVisible from '@/utility/useVisible';
|
||||||
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
|
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
|
||||||
|
import { SortBy } from './db/sort';
|
||||||
|
|
||||||
export type RevalidatePhoto = (
|
export type RevalidatePhoto = (
|
||||||
photoId: string,
|
photoId: string,
|
||||||
@ -29,6 +29,7 @@ export default function InfinitePhotoScroll({
|
|||||||
initialOffset,
|
initialOffset,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
camera,
|
camera,
|
||||||
lens,
|
lens,
|
||||||
tag,
|
tag,
|
||||||
@ -42,7 +43,8 @@ export default function InfinitePhotoScroll({
|
|||||||
}: {
|
}: {
|
||||||
initialOffset: number
|
initialOffset: number
|
||||||
itemsPerPage: number
|
itemsPerPage: number
|
||||||
sortBy?: GetPhotosOptions['sortBy']
|
sortBy?: SortBy
|
||||||
|
sortWithPriority?: boolean
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
wrapMoreButtonInGrid?: boolean
|
wrapMoreButtonInGrid?: boolean
|
||||||
useCachedPhotos?: boolean
|
useCachedPhotos?: boolean
|
||||||
@ -69,7 +71,8 @@ export default function InfinitePhotoScroll({
|
|||||||
) =>
|
) =>
|
||||||
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
|
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
|
||||||
offset: initialOffset + size * itemsPerPage,
|
offset: initialOffset + size * itemsPerPage,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
hidden: includeHiddenPhotos ? 'include' : 'exclude',
|
hidden: includeHiddenPhotos ? 'include' : 'exclude',
|
||||||
camera,
|
camera,
|
||||||
@ -82,6 +85,7 @@ export default function InfinitePhotoScroll({
|
|||||||
, [
|
, [
|
||||||
useCachedPhotos,
|
useCachedPhotos,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
initialOffset,
|
initialOffset,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
includeHiddenPhotos,
|
includeHiddenPhotos,
|
||||||
|
|||||||
@ -4,19 +4,26 @@ import {
|
|||||||
} from '.';
|
} from '.';
|
||||||
import PhotosLarge from './PhotosLarge';
|
import PhotosLarge from './PhotosLarge';
|
||||||
import PhotosLargeInfinite from './PhotosLargeInfinite';
|
import PhotosLargeInfinite from './PhotosLargeInfinite';
|
||||||
|
import { SortBy } from './db/sort';
|
||||||
|
|
||||||
export default function PhotoFeedPage({
|
export default function PhotoFeedPage({
|
||||||
photos,
|
photos,
|
||||||
photosCount,
|
photosCount,
|
||||||
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
}:{
|
}:{
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
photosCount: number
|
photosCount: number
|
||||||
|
sortBy: SortBy
|
||||||
|
sortWithPriority: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<PhotosLarge {...{ photos }} />
|
<PhotosLarge {...{ photos }} />
|
||||||
{photosCount > photos.length &&
|
{photosCount > photos.length &&
|
||||||
<PhotosLargeInfinite
|
<PhotosLargeInfinite
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortWithPriority={sortWithPriority}
|
||||||
initialOffset={photos.length}
|
initialOffset={photos.length}
|
||||||
itemsPerPage={INFINITE_SCROLL_FEED_MULTIPLE}
|
itemsPerPage={INFINITE_SCROLL_FEED_MULTIPLE}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@ -7,11 +7,14 @@ import { clsx } from 'clsx/lite';
|
|||||||
import AnimateItems from '@/components/AnimateItems';
|
import AnimateItems from '@/components/AnimateItems';
|
||||||
import { ComponentProps, useCallback, useState, ReactNode } from 'react';
|
import { ComponentProps, useCallback, useState, ReactNode } from 'react';
|
||||||
import { GRID_SPACE_CLASSNAME } from '@/components';
|
import { GRID_SPACE_CLASSNAME } from '@/components';
|
||||||
|
import { SortBy } from './db/sort';
|
||||||
|
|
||||||
export default function PhotoGridContainer({
|
export default function PhotoGridContainer({
|
||||||
cacheKey,
|
cacheKey,
|
||||||
photos,
|
photos,
|
||||||
count,
|
count,
|
||||||
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
animateOnFirstLoadOnly,
|
animateOnFirstLoadOnly,
|
||||||
header,
|
header,
|
||||||
sidebar,
|
sidebar,
|
||||||
@ -20,6 +23,8 @@ export default function PhotoGridContainer({
|
|||||||
}: {
|
}: {
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
count: number
|
count: number
|
||||||
|
sortBy?: SortBy
|
||||||
|
sortWithPriority?: boolean
|
||||||
header?: ReactNode
|
header?: ReactNode
|
||||||
sidebar?: ReactNode
|
sidebar?: ReactNode
|
||||||
} & ComponentProps<typeof PhotoGrid>) {
|
} & ComponentProps<typeof PhotoGrid>) {
|
||||||
@ -54,6 +59,8 @@ export default function PhotoGridContainer({
|
|||||||
<PhotoGridInfinite {...{
|
<PhotoGridInfinite {...{
|
||||||
cacheKey,
|
cacheKey,
|
||||||
initialOffset: photos.length,
|
initialOffset: photos.length,
|
||||||
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
...categories,
|
...categories,
|
||||||
canStart: shouldAnimateDynamicItems,
|
canStart: shouldAnimateDynamicItems,
|
||||||
animateOnFirstLoadOnly,
|
animateOnFirstLoadOnly,
|
||||||
|
|||||||
@ -4,10 +4,13 @@ import { INFINITE_SCROLL_GRID_MULTIPLE } from '.';
|
|||||||
import InfinitePhotoScroll from './InfinitePhotoScroll';
|
import InfinitePhotoScroll from './InfinitePhotoScroll';
|
||||||
import PhotoGrid from './PhotoGrid';
|
import PhotoGrid from './PhotoGrid';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
|
import { SortBy } from './db/sort';
|
||||||
|
|
||||||
export default function PhotoGridInfinite({
|
export default function PhotoGridInfinite({
|
||||||
cacheKey,
|
cacheKey,
|
||||||
initialOffset,
|
initialOffset,
|
||||||
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
canStart,
|
canStart,
|
||||||
animateOnFirstLoadOnly,
|
animateOnFirstLoadOnly,
|
||||||
canSelect,
|
canSelect,
|
||||||
@ -15,12 +18,16 @@ export default function PhotoGridInfinite({
|
|||||||
}: {
|
}: {
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
initialOffset: number
|
initialOffset: number
|
||||||
|
sortBy?: SortBy
|
||||||
|
sortWithPriority?: boolean
|
||||||
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
|
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
|
||||||
return (
|
return (
|
||||||
<InfinitePhotoScroll
|
<InfinitePhotoScroll
|
||||||
cacheKey={cacheKey}
|
cacheKey={cacheKey}
|
||||||
initialOffset={initialOffset}
|
initialOffset={initialOffset}
|
||||||
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
|
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortWithPriority={sortWithPriority}
|
||||||
{...categories}
|
{...categories}
|
||||||
>
|
>
|
||||||
{({ photos, onLastPhotoVisible }) =>
|
{({ photos, onLastPhotoVisible }) =>
|
||||||
|
|||||||
@ -10,14 +10,19 @@ import clsx from 'clsx/lite';
|
|||||||
import useElementHeight from '@/utility/useElementHeight';
|
import useElementHeight from '@/utility/useElementHeight';
|
||||||
import MaskedScroll from '@/components/MaskedScroll';
|
import MaskedScroll from '@/components/MaskedScroll';
|
||||||
import { IS_RECENTS_FIRST } from '@/app/config';
|
import { IS_RECENTS_FIRST } from '@/app/config';
|
||||||
|
import { SortBy } from './db/sort';
|
||||||
|
|
||||||
export default function PhotoGridPageClient({
|
export default function PhotoGridPageClient({
|
||||||
photos,
|
photos,
|
||||||
photosCount,
|
photosCount,
|
||||||
|
sortBy,
|
||||||
|
sortWithPriority,
|
||||||
...categories
|
...categories
|
||||||
}: ComponentProps<typeof PhotoGridSidebar> & {
|
}: ComponentProps<typeof PhotoGridSidebar> & {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
photosCount: number
|
photosCount: number
|
||||||
|
sortBy: SortBy
|
||||||
|
sortWithPriority: boolean
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -35,6 +40,8 @@ export default function PhotoGridPageClient({
|
|||||||
cacheKey={`page-${PATH_GRID_INFERRED}`}
|
cacheKey={`page-${PATH_GRID_INFERRED}`}
|
||||||
photos={photos}
|
photos={photos}
|
||||||
count={photosCount}
|
count={photosCount}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortWithPriority={sortWithPriority}
|
||||||
sidebar={
|
sidebar={
|
||||||
<MaskedScroll
|
<MaskedScroll
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
|
|||||||
import ImageInput from '../components/ImageInput';
|
import ImageInput from '../components/ImageInput';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { RefObject, useTransition } from 'react';
|
import { RefObject, useTransition, useRef, useEffect } from 'react';
|
||||||
import { useRef } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|||||||
@ -3,19 +3,24 @@
|
|||||||
import { PATH_FEED_INFERRED } from '@/app/paths';
|
import { PATH_FEED_INFERRED } from '@/app/paths';
|
||||||
import InfinitePhotoScroll from './InfinitePhotoScroll';
|
import InfinitePhotoScroll from './InfinitePhotoScroll';
|
||||||
import PhotosLarge from './PhotosLarge';
|
import PhotosLarge from './PhotosLarge';
|
||||||
|
import { SortBy } from './db/sort';
|
||||||
|
|
||||||
export default function PhotosLargeInfinite({
|
export default function PhotosLargeInfinite({
|
||||||
initialOffset,
|
initialOffset,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
|
sortBy,
|
||||||
}: {
|
}: {
|
||||||
initialOffset: number
|
initialOffset: number
|
||||||
itemsPerPage: number
|
itemsPerPage: number
|
||||||
|
sortBy: SortBy
|
||||||
|
sortWithPriority: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<InfinitePhotoScroll
|
<InfinitePhotoScroll
|
||||||
cacheKey={`page-${PATH_FEED_INFERRED}`}
|
cacheKey={`page-${PATH_FEED_INFERRED}`}
|
||||||
initialOffset={initialOffset}
|
initialOffset={initialOffset}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
|
sortBy={sortBy}
|
||||||
wrapMoreButtonInGrid
|
wrapMoreButtonInGrid
|
||||||
>
|
>
|
||||||
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
|
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { PRIORITY_ORDER_ENABLED } from '@/app/config';
|
|
||||||
import { parameterize } from '@/utility/string';
|
import { parameterize } from '@/utility/string';
|
||||||
import { PhotoSetCategory } from '../../category';
|
import { PhotoSetCategory } from '../../category';
|
||||||
import { Camera } from '@/camera';
|
import { Camera } from '@/camera';
|
||||||
import { Lens } from '@/lens';
|
import { Lens } from '@/lens';
|
||||||
|
import { APP_DEFAULT_SORT_BY, SortBy } from './sort';
|
||||||
|
|
||||||
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
||||||
export const PHOTO_DEFAULT_LIMIT = 100;
|
export const PHOTO_DEFAULT_LIMIT = 100;
|
||||||
@ -20,7 +20,8 @@ const parameterizeForDb = (field: string) =>
|
|||||||
, `LOWER(TRIM(${field}))`);
|
, `LOWER(TRIM(${field}))`);
|
||||||
|
|
||||||
export type GetPhotosOptions = {
|
export type GetPhotosOptions = {
|
||||||
sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority'
|
sortBy?: SortBy
|
||||||
|
sortWithPriority?: boolean
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
query?: string
|
query?: string
|
||||||
@ -146,18 +147,27 @@ export const getWheresFromOptions = (
|
|||||||
|
|
||||||
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
||||||
const {
|
const {
|
||||||
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
|
sortBy = APP_DEFAULT_SORT_BY,
|
||||||
|
sortWithPriority,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'createdAt':
|
|
||||||
return 'ORDER BY created_at DESC';
|
|
||||||
case 'createdAtAsc':
|
|
||||||
return 'ORDER BY created_at ASC';
|
|
||||||
case 'takenAt':
|
case 'takenAt':
|
||||||
return 'ORDER BY taken_at DESC';
|
return sortWithPriority
|
||||||
case 'priority':
|
? 'ORDER BY priority_order ASC, taken_at DESC'
|
||||||
return 'ORDER BY priority_order ASC, taken_at DESC';
|
: 'ORDER BY taken_at DESC';
|
||||||
|
case 'takenAtAsc':
|
||||||
|
return sortWithPriority
|
||||||
|
? 'ORDER BY priority_order ASC, taken_at ASC'
|
||||||
|
: 'ORDER BY taken_at ASC';
|
||||||
|
case 'createdAt':
|
||||||
|
return sortWithPriority
|
||||||
|
? 'ORDER BY priority_order ASC, created_at DESC'
|
||||||
|
: 'ORDER BY created_at DESC';
|
||||||
|
case 'createdAtAsc':
|
||||||
|
return sortWithPriority
|
||||||
|
? 'ORDER BY priority_order ASC, created_at ASC'
|
||||||
|
: 'ORDER BY created_at ASC';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,10 +22,10 @@ import {
|
|||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import {
|
import {
|
||||||
GetPhotosOptions,
|
GetPhotosOptions,
|
||||||
getLimitAndOffsetFromOptions,
|
|
||||||
getOrderByFromOptions,
|
getOrderByFromOptions,
|
||||||
|
getLimitAndOffsetFromOptions,
|
||||||
|
getWheresFromOptions,
|
||||||
} from '.';
|
} from '.';
|
||||||
import { getWheresFromOptions } from '.';
|
|
||||||
import { FocalLengths } from '@/focal';
|
import { FocalLengths } from '@/focal';
|
||||||
import { Lenses, createLensKey } from '@/lens';
|
import { Lenses, createLensKey } from '@/lens';
|
||||||
import { migrationForError } from './migration';
|
import { migrationForError } from './migration';
|
||||||
@ -120,7 +120,7 @@ const safelyQueryPhotos = async <T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (/relation "photos" does not exist/i.test(e.message)) {
|
} else if (/relation "photos" does not exist/i.test(e.message)) {
|
||||||
// If the table does not exist, create it
|
// If table doesn't exist, create it
|
||||||
console.log('Creating photos table ...');
|
console.log('Creating photos table ...');
|
||||||
await createPhotosTable();
|
await createPhotosTable();
|
||||||
result = await callback();
|
result = await callback();
|
||||||
|
|||||||
128
src/photo/db/sort-path.ts
Normal file
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 {
|
import {
|
||||||
convertApertureValueToFNumber,
|
convertApertureValueToFNumber,
|
||||||
getAspectRatioFromExif,
|
getAspectRatioFromExif,
|
||||||
|
getOffsetFromExif,
|
||||||
} from '@/utility/exif';
|
} from '@/utility/exif';
|
||||||
|
import {
|
||||||
|
convertTimestampWithOffsetToPostgresString,
|
||||||
|
convertTimestampToNaivePostgresString,
|
||||||
|
} from '@/utility/date';
|
||||||
import { GEO_PRIVACY_ENABLED } from '@/app/config';
|
import { GEO_PRIVACY_ENABLED } from '@/app/config';
|
||||||
import { convertTimestampWithOffsetToPostgresString } from '@/utility/date';
|
|
||||||
import { convertTimestampToNaivePostgresString } from '@/utility/date';
|
|
||||||
import { getOffsetFromExif } from '@/utility/exif';
|
|
||||||
import { PhotoExif } from '..';
|
import { PhotoExif } from '..';
|
||||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||||
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange, descriptionForPhotoSet } from '@/photo';
|
||||||
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
||||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
import { descriptionForPhotoSet } from '@/photo';
|
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function RecentsOGTile({
|
export default function RecentsOGTile({
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
|
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
|
||||||
import { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo';
|
import {
|
||||||
import { PhotoDateRange } from '@/photo';
|
descriptionForPhotoSet,
|
||||||
|
Photo,
|
||||||
|
photoQuantityText,
|
||||||
|
PhotoDateRange,
|
||||||
|
} from '@/photo';
|
||||||
import {
|
import {
|
||||||
capitalizeWords,
|
capitalizeWords,
|
||||||
formatCount,
|
formatCount,
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, ReactNode, useCallback, useRef } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
import { AppStateContext } from './AppState';
|
import { AppStateContext } from './AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import usePathnames from '@/utility/usePathnames';
|
import usePathnames from '@/utility/usePathnames';
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, RefObject, useEffect } from 'react';
|
||||||
|
|
||||||
import { RefObject, useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function useElementHeight(
|
export default function useElementHeight(
|
||||||
ref: RefObject<HTMLElement | null>,
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange, descriptionForPhotoSet } from '@/photo';
|
||||||
import { pathForYear, pathForYearImage } from '@/app/paths';
|
import { pathForYear, pathForYearImage } from '@/app/paths';
|
||||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
import { descriptionForPhotoSet } from '@/photo';
|
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function YearOGTile({
|
export default function YearOGTile({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user