Exclude photo from feeds (#280)

* Add tooltip to 'hidden' checkbox

* Refine checkbox UI

* Allow photos to be excluded from main feeds

* Fix footer grid in photos excluded from feed

* Apply feed exclusion from batch upload

* Scrub final hidden/private language

* Add visibility icons to admin photo menu
This commit is contained in:
Sam Becker 2025-07-05 23:40:58 -05:00 committed by GitHub
parent bf78f786a7
commit 70f6f48044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 560 additions and 368 deletions

View File

@ -146,14 +146,14 @@ Application behavior can be changed by configuring the following environment var
#### Sorting
- `NEXT_PUBLIC_DEFAULT_SORT`
- Sets default sort on grid/feed homepages
- Sets default sort on grid/full 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
- `NEXT_PUBLIC_SHOW_SORT_CONTROL = 1` shows sort control in desktop nav on grid/full homepages
#### Display
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
@ -317,8 +317,8 @@ Thank you ❤️ translators: [@sconetto](https://github.com/sconetto) (`pt-br`,
#### Why are my grid thumbnails so small?
> Thumbnail grid density (seen on `/grid`, tag overviews, and other photo sets) is dependent on aspect ratio configuration (ratios of 1 or less have more photos per row). This can be overridden by setting `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1`.
#### How secure are photos marked “hidden?”
> While all hidden paths (`/tag/hidden/*`) require authentication, raw links to individual photo assets remain publicly accessible. Randomly generated urls from storage providers are only secure via obscurity. Use with caution.
#### How secure are photos marked “private?”
> While all private paths (`/tag/private/*`) require authentication, raw links to individual photo assets remain publicly accessible. Randomly generated urls from storage providers are only secure via obscurity. Use with caution.
#### My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?
> Navigate to `/admin/configuration` and click "Clear Cache."

View File

@ -12,7 +12,7 @@ import {
isPathTag,
isPathTagPhoto,
} from '@/app/paths';
import { TAG_HIDDEN } from '@/tag';
import { TAG_PRIVATE } from '@/tag';
const PHOTO_ID = 'UsKSGcbt';
const TAG = 'tag-name';
@ -25,7 +25,7 @@ const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`;
const PATH_ROOT = '/';
const PATH_GRID = '/grid';
const PATH_FEED = '/feed';
const PATH_FULL = '/full';
const PATH_ADMIN = '/admin/photos';
const PATH_OG = '/og';
const PATH_OG_ALL = `${PATH_OG}/all`;
@ -36,8 +36,8 @@ const PATH_PHOTO = `/p/${PHOTO_ID}`;
const PATH_TAG = `/tag/${TAG}`;
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
const PATH_TAG_HIDDEN = `/tag/${TAG_HIDDEN}`;
const PATH_TAG_HIDDEN_PHOTO = `${PATH_TAG_HIDDEN}/${PHOTO_ID}`;
const PATH_TAG_PRIVATE = `/tag/${TAG_PRIVATE}`;
const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`;
const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`;
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`;
@ -62,8 +62,8 @@ describe('Paths', () => {
expect(isPathProtected(PATH_OG)).toBe(true);
expect(isPathProtected(PATH_OG_ALL)).toBe(true);
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
expect(isPathProtected(PATH_TAG_HIDDEN)).toBe(true);
expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).toBe(true);
expect(isPathProtected(PATH_TAG_PRIVATE)).toBe(true);
expect(isPathProtected(PATH_TAG_PRIVATE_PHOTO)).toBe(true);
});
it('can be classified', () => {
// Positive
@ -123,7 +123,7 @@ describe('Paths', () => {
// Root
expect(getEscapePath(PATH_ROOT)).toEqual(undefined);
expect(getEscapePath(PATH_GRID)).toEqual(undefined);
expect(getEscapePath(PATH_FEED)).toEqual(undefined);
expect(getEscapePath(PATH_FULL)).toEqual(undefined);
expect(getEscapePath(PATH_ADMIN)).toEqual(undefined);
// Photo
expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_ROOT);

View File

@ -1,17 +1,15 @@
import { getPhotosCached } from '@/photo/cache';
import { SITE_FEEDS_ENABLED } from '@/app/config';
import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed';
import { formatFeedJson } from '@/feed/json';
import { PROGRAMMATIC_QUERY_OPTIONS } from '@/feed';
// Cache for 24 hours
export const revalidate = 86_400;
export async function GET() {
if (SITE_FEEDS_ENABLED) {
const photos = await getPhotosCached({
limit: FEED_PHOTO_REQUEST_LIMIT,
sortBy: 'createdAt',
}).catch(() => []);
const photos = await getPhotosCached(PROGRAMMATIC_QUERY_OPTIONS)
.catch(() => []);
return Response.json(formatFeedJson(photos));
} else {
return new Response('Feeds disabled', { status: 404 });

View File

@ -1,52 +1,51 @@
import {
INFINITE_SCROLL_FEED_INITIAL,
generateOgImageMetaForPhotos,
} from '@/photo';
import { 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 PhotoFullPage from '@/photo/PhotoFullPage';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/db/sort';
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
import { PhotoQueryOptions } from '@/photo/db';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
export const maxDuration = 60;
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL,
}));
const getPhotosCached = cache((options: PhotoQueryOptions) =>
getPhotos(getFeedQueryOptions({
isGrid: false,
...options,
})));
export async function generateMetadata({
params,
}: SortProps): Promise<Metadata> {
const options = await getSortOptionsFromParams(params);
const photos = await getPhotosCached(options)
const sortOptions = await getSortOptionsFromParams(params);
const photos = await getPhotosCached(sortOptions)
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function FeedPageSort({ params }: SortProps) {
const options = await getSortOptionsFromParams(params);
export default async function FullPageSort({ params }: SortProps) {
const sortOptions = await getSortOptionsFromParams(params);
const [
photos,
photosCount,
] = await Promise.all([
getPhotosCached(options)
getPhotosCached(sortOptions)
.catch(() => []),
getPhotosMetaCached(options)
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
]);
return (
photos.length > 0
? <PhotoFeedPage {...{
? <PhotoFullPage {...{
photos,
photosCount,
...options,
...sortOptions,
}} />
: <PhotosEmptyState />
);

View File

@ -1,45 +1,41 @@
import {
INFINITE_SCROLL_FEED_INITIAL,
generateOgImageMetaForPhotos,
} from '@/photo';
import { 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 PhotoFullPage from '@/photo/PhotoFullPage';
import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { PhotoQueryOptions } from '@/photo/db';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL,
}));
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
isGrid: false,
})));
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
const photos = await getPhotosCached()
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function FeedPage() {
export default async function FullPage() {
const [
photos,
photosCount,
] = await Promise.all([
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
getPhotosCached()
.catch(() => []),
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
]);
return (
photos.length > 0
? <PhotoFeedPage {...{
? <PhotoFullPage {...{
photos,
photosCount,
...USER_DEFAULT_SORT_OPTIONS,

View File

@ -1,7 +1,4 @@
import {
INFINITE_SCROLL_GRID_INITIAL,
generateOgImageMetaForPhotos,
} from '@/photo';
import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { getPhotos } from '@/photo/db/query';
@ -11,34 +8,36 @@ import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/db/sort';
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { PhotoQueryOptions } from '@/photo/db';
export const maxDuration = 60;
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL,
}));
const getPhotosCached = cache((options: PhotoQueryOptions) =>
getPhotos(getFeedQueryOptions({
isGrid: true,
...options,
})));
export async function generateMetadata({
params,
}: SortProps): Promise<Metadata> {
const options = await getSortOptionsFromParams(params);
const photos = await getPhotosCached(options)
const sortOptions = await getSortOptionsFromParams(params);
const photos = await getPhotosCached(sortOptions)
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function GridPage({ params }: SortProps) {
const options = await getSortOptionsFromParams(params);
const sortOptions = await getSortOptionsFromParams(params);
const [
photos,
photosCount,
categories,
] = await Promise.all([
getPhotosCached(options)
getPhotosCached(sortOptions)
.catch(() => []),
getPhotosMetaCached(options)
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
getDataForCategoriesCached(),
@ -50,7 +49,7 @@ export default async function GridPage({ params }: SortProps) {
{...{
photos,
photosCount,
...options,
...sortOptions,
...categories,
}}
/>

View File

@ -1,7 +1,4 @@
import {
INFINITE_SCROLL_GRID_INITIAL,
generateOgImageMetaForPhotos,
} from '@/photo';
import { generateOgImageMetaForPhotos } from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { getPhotos } from '@/photo/db/query';
@ -10,18 +7,17 @@ import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { PhotoQueryOptions } from '@/photo/db';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL,
}));
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
isGrid: true,
})));
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
const photos = await getPhotosCached()
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
@ -32,9 +28,9 @@ export default async function GridPage() {
photosCount,
categories,
] = await Promise.all([
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
getPhotosCached()
.catch(() => []),
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
getDataForCategoriesCached(),

View File

@ -30,6 +30,7 @@ import RecipeModal from '@/recipe/RecipeModal';
import ThemeColors from '@/app/ThemeColors';
import AppTextProvider from '@/i18n/state/AppTextProvider';
import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider';
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
import '../tailwind.css';
@ -71,7 +72,8 @@ export const metadata: Metadata = {
...SITE_FEEDS_ENABLED && {
alternates: {
types: {
'application/rss+xml': '/rss.xml',
'application/rss+xml': PATH_RSS_XML,
'application/json': PATH_FEED_JSON,
},
},
},

View File

@ -18,7 +18,13 @@ import { staticallyGeneratePhotosIfConfigured } from '@/app/static';
export const maxDuration = 60;
const getPhotosNearIdCachedCached = cache((photoId: string) =>
getPhotosNearIdCached(photoId, { limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 }));
getPhotosNearIdCached(
photoId, {
limit: RELATED_GRID_PHOTOS_TO_SHOW + 2,
},
// Don't show photo in context when excluded from feeds
true,
));
export const generateStaticParams = staticallyGeneratePhotosIfConfigured(
'page',

View File

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

View File

@ -1,17 +1,15 @@
import { getPhotosCached } from '@/photo/cache';
import { SITE_FEEDS_ENABLED } from '@/app/config';
import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed';
import { formatFeedRssXml } from '@/feed/rss';
import { PROGRAMMATIC_QUERY_OPTIONS } from '@/feed';
// Cache for 24 hours
export const revalidate = 86_400;
export async function GET() {
if (SITE_FEEDS_ENABLED) {
const photos = await getPhotosCached({
limit: FEED_PHOTO_REQUEST_LIMIT,
sortBy: 'createdAt',
}).catch(() => []);
const photos = await getPhotosCached(PROGRAMMATIC_QUERY_OPTIONS)
.catch(() => []);
return new Response(
formatFeedRssXml(photos),
{ headers: { 'Content-Type': 'text/xml' } },

View File

@ -1,6 +1,8 @@
import type { MetadataRoute } from 'next';
import { getDataForCategoriesCached } from '@/category/cache';
import {
ABSOLUTE_PATH_FULL,
ABSOLUTE_PATH_GRID,
absolutePathForCamera,
absolutePathForFilm,
absolutePathForFocalLength,
@ -72,9 +74,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: PRIORITY_HOME,
lastModified: lastModifiedSite,
},
// Grid or Feed
// Grid or full
{
url: GRID_HOMEPAGE_ENABLED ? `${BASE_URL}/feed` : `${BASE_URL}/grid`,
url: GRID_HOMEPAGE_ENABLED ? ABSOLUTE_PATH_FULL : ABSOLUTE_PATH_GRID,
priority: PRIORITY_HOME_VIEW,
lastModified: lastModifiedSite,
},

View File

@ -9,7 +9,7 @@ import {
getPhotosNearIdCached,
} from '@/photo/cache';
import { PATH_ROOT, absolutePathForPhoto } from '@/app/paths';
import { TAG_HIDDEN } from '@/tag';
import { TAG_PRIVATE } from '@/tag';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { cache } from 'react';
@ -36,7 +36,7 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const descriptionHtml = descriptionForPhoto(photo, true);
const url = absolutePathForPhoto({ photo, tag: TAG_HIDDEN });
const url = absolutePathForPhoto({ photo, tag: TAG_PRIVATE });
return {
title,
@ -54,7 +54,7 @@ export async function generateMetadata({
};
}
export default async function PhotoTagHiddenPage({
export default async function PhotoTagPrivatePage({
params,
}: PhotoTagProps) {
const { photoId } = await params;
@ -74,7 +74,7 @@ export default async function PhotoTagHiddenPage({
indexNumber,
count,
dateRange,
tag: TAG_HIDDEN,
tag: TAG_PRIVATE,
shouldShare: false,
includeFavoriteInAdminMenu: false,
}} />

View File

@ -4,8 +4,8 @@ import AppGrid from '@/components/AppGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotosMetaCached, getPhotosNoStore } from '@/photo/cache';
import { absolutePathForTag } from '@/app/paths';
import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader';
import { TAG_PRIVATE, descriptionForTaggedPhotos, titleForTag } from '@/tag';
import PrivateHeader from '@/tag/PrivateHeader';
import { Metadata } from 'next';
import { cache } from 'react';
import { getAppText } from '@/i18n/state/server';
@ -20,7 +20,7 @@ export async function generateMetadata(): Promise<Metadata> {
const appText = await getAppText();
const title = titleForTag(TAG_HIDDEN, undefined, appText, count);
const title = titleForTag(TAG_PRIVATE, undefined, appText, count);
const description = descriptionForTaggedPhotos(
undefined,
@ -29,7 +29,7 @@ export async function generateMetadata(): Promise<Metadata> {
count,
dateRange,
);
const url = absolutePathForTag(TAG_HIDDEN);
const url = absolutePathForTag(TAG_PRIVATE);
return {
title,
@ -46,7 +46,7 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
export default async function HiddenTagPage() {
export default async function PrivateTagPage() {
const [
photos,
{ count, dateRange },
@ -60,15 +60,15 @@ export default async function HiddenTagPage() {
contentMain={<div className="space-y-4 mt-4">
<AnimateItems
type="bottom"
items={[<HiddenHeader
key="HiddenHeader"
items={[<PrivateHeader
key="PrivateHeader"
{...{ photos, count, dateRange }}
/>]}
animateOnFirstLoadOnly
/>
<div className="space-y-6">
<Note animate>
Only visible to authenticated admins
Visible only to admins (uploads only secure via obscurity)
</Note>
<PhotoGrid {...{ photos }} />
</div>

View File

@ -46,12 +46,12 @@ export const config = {
// - /_next/image*
// - /favicon.ico + /favicons/*
// - /grid
// - /feed
// - /full
// - / (root)
// - /home-image
// - /template-image
// - /template-image-tight
// - /template-url
// eslint-disable-next-line max-len
matcher: ['/((?!api$|api/auth|_next/static|_next/image|favicon.ico$|favicons/|grid$|feed$|home-image$|template-image$|template-image-tight$|template-url$|$).*)'],
matcher: ['/((?!api$|api/auth|_next/static|_next/image|favicon.ico$|favicons/|grid$|full$|home-image$|template-image$|template-image-tight$|template-url$|$).*)'],
};

View File

@ -635,7 +635,7 @@ export default function AdminAppConfigurationClient({
)}
</Fragment>)}
</div>
Change default sort on grid/feed homepages
Change default sort on grid/full homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
</ChecklistRow>
<ChecklistRow
@ -654,7 +654,7 @@ export default function AdminAppConfigurationClient({
optional
>
Set environment variable to {'"1"'} to
show sort control in desktop nav on grid/feed homepages:
show sort control in desktop nav on grid/full homepages:
{renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
</ChecklistRow>
</ChecklistGroup>

View File

@ -13,7 +13,7 @@ import {
import sleep from '@/utility/sleep';
import { readStreamableValue } from 'ai/rsc';
import { useRouter } from 'next/navigation';
import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { BiCheckCircle } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient';
@ -22,8 +22,9 @@ import DeleteUploadButton from './DeleteUploadButton';
import { useAppState } from '@/app/AppState';
import { pluralize } from '@/utility/string';
import FieldsetFavs from '@/photo/form/FieldsetFavs';
import FieldsetHidden from '@/photo/form/FieldsetHidden';
import FieldsetPrivate from '@/photo/form/FieldsetPrivate';
import IconAddUpload from '@/components/icons/IconAddUpload';
import FieldsetExclude from '@/photo/form/FieldsetExclude';
const UPLOAD_BATCH_SIZE = 2;
@ -53,6 +54,7 @@ export default function AdminBatchUploadActions({
const [showBulkSettings, setShowBulkSettings] = useState(false);
const [tags, setTags] = useState('');
const [favorite, setFavorite] = useState('false');
const [excludeFromFeeds, setExcludeFromFeeds] = useState('false');
const [hidden, setHidden] = useState('false');
const [tagErrorMessage, setTagErrorMessage] = useState('');
@ -76,6 +78,7 @@ export default function AdminBatchUploadActions({
...showBulkSettings && {
tags,
favorite,
excludeFromFeeds,
hidden,
},
takenAtLocal: generateLocalPostgresString(),
@ -123,12 +126,19 @@ export default function AdminBatchUploadActions({
}
};
useEffect(() => {
if (hidden === 'true') {
setFavorite('false');
setExcludeFromFeeds('false');
}
}, [hidden]);
return (
<>
{actionErrorMessage &&
<ErrorNote>{actionErrorMessage}</ErrorNote>}
<Container padding="tight">
<div className="w-full space-y-4 py-1">
<Container padding="tight" className="p-2! sm:p-3!">
<div className="w-full space-y-4">
<div className="flex">
<div className="grow text-main">
{showBulkSettings
@ -154,20 +164,25 @@ export default function AdminBatchUploadActions({
readOnly={isAdding}
className="relative z-10"
/>
<div className="flex gap-8">
<div className="flex max-sm:flex-col gap-x-8 gap-y-4">
<FieldsetFavs
value={favorite}
onChange={setFavorite}
readOnly={isAdding}
readOnly={isAdding || hidden === 'true'}
/>
<FieldsetHidden
<FieldsetExclude
value={excludeFromFeeds}
onChange={setExcludeFromFeeds}
readOnly={isAdding || hidden === 'true'}
/>
<FieldsetPrivate
value={hidden}
onChange={setHidden}
readOnly={isAdding}
/>
</div>
</div>}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row-reverse gap-2">
<ProgressButton
primary
className="w-full justify-center"

View File

@ -11,14 +11,14 @@ import {
deletePhotoAction,
syncPhotoAction,
toggleFavoritePhotoAction,
toggleHidePhotoAction,
togglePrivatePhotoAction,
} from '@/photo/actions';
import {
Photo,
deleteConfirmationTextForPhoto,
downloadFileNameForPhoto,
} from '@/photo';
import { isPathFavs, isPhotoFav, TAG_HIDDEN } from '@/tag';
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/more/MoreMenu';
@ -33,7 +33,7 @@ import IconEdit from '@/components/icons/IconEdit';
import { photoNeedsToBeSynced } from '@/photo/sync';
import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client';
import IconHidden from '@/components/icons/IconHidden';
import IconLock from '@/components/icons/IconLock';
export default function AdminPhotoMenu({
photo,
@ -57,9 +57,9 @@ export default function AdminPhotoMenu({
const isFav = isPhotoFav(photo);
const shouldRedirectFav = isPathFavs(path) && isFav;
const shouldRedirectDelete = isOnPhotoDetail;
const redirectPathOnHideToggle = isOnPhotoDetail
const redirectPathOnPrivateToggle = isOnPhotoDetail
? photo.hidden
? pathForTag(TAG_HIDDEN)
? pathForTag(TAG_PRIVATE)
: PATH_ROOT
: undefined;
@ -68,8 +68,8 @@ export default function AdminPhotoMenu({
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: appText.admin.edit,
icon: <IconEdit
size={15}
className="translate-x-[0.5px]"
size={14}
className="translate-x-[0.5px] translate-y-[0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
...showKeyCommands && { keyCommand: KEY_COMMANDS.edit },
@ -94,19 +94,20 @@ export default function AdminPhotoMenu({
});
}
items.push({
label: photo.hidden ? appText.admin.unhide : appText.admin.hide,
icon: <IconHidden
size={17}
className="translate-x-[-1px] translate-y-[1px]"
visible={photo.hidden}
label: photo.hidden ? appText.admin.public : appText.admin.private,
icon: <IconLock
size={16}
className="translate-x-[-1.5px] translate-y-[0.5px]"
open={!photo.hidden}
narrow
/>,
action: () => toggleHidePhotoAction(
action: () => togglePrivatePhotoAction(
photo.id,
redirectPathOnHideToggle,
redirectPathOnPrivateToggle,
)
.then(() => revalidatePhoto?.(photo.id)),
...showKeyCommands && {
keyCommand: KEY_COMMANDS.toggleHide,
keyCommand: KEY_COMMANDS.togglePrivate,
},
});
items.push({
@ -146,7 +147,7 @@ export default function AdminPhotoMenu({
includeFavorite,
isFav,
shouldRedirectFav,
redirectPathOnHideToggle,
redirectPathOnPrivateToggle,
revalidatePhoto,
]);

View File

@ -17,6 +17,7 @@ import { Timezone } from '@/utility/timezone';
import IconHidden from '@/components/icons/IconHidden';
import Tooltip from '@/components/Tooltip';
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
import IconLock from '@/components/icons/IconLock';
export default function AdminPhotosTable({
photos,
@ -76,13 +77,20 @@ export default function AdminPhotosTable({
<span className="truncate">
{titleForPhoto(photo, false)}
</span>
{photo.hidden &&
{photo.excludeFromFeeds && !photo.hidden &&
<span>
<IconHidden
className="inline translate-y-[-0.5px]"
className="inline translate-y-[-1px]"
size={16}
/>
</span>}
{photo.hidden &&
<span>
<IconLock
size={13}
className="inline translate-y-[-1.5px]"
/>
</span>}
</span>
{photo.priorityOrder !== null &&
<span className={clsx(

View File

@ -1,7 +1,7 @@
import PhotoTag from '@/tag/PhotoTag';
import { photoLabelForCount } from '@/photo';
import { clsx } from 'clsx/lite';
import FavsTag from '@/tag/FavsTag';
import PhotoFavs from '@/tag/PhotoFavs';
import { isTagFavs } from '@/tag';
import Badge from '@/components/Badge';
import { getAppText } from '@/i18n/state/server';
@ -25,7 +25,7 @@ export default async function AdminTagBadge({
isTagFavs(tag) && 'translate-y-[0.5px]',
)}>
{isTagFavs(tag)
? <FavsTag />
? <PhotoFavs />
: <PhotoTag {...{ tag }} />}
<div className="text-dim uppercase">
<span>{count}</span>

View File

@ -75,7 +75,7 @@ export default function AdminUploadsTableRow({
className={clsx(
'flex items-center grow',
'transition-opacity',
'rounded-md overflow-hidden',
'rounded-lg overflow-hidden',
'border-medium bg-extra-dim',
isAdding && !isComplete && status !== 'adding' && 'opacity-30',
)}

View File

@ -1,10 +1,10 @@
import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem';
import IconFeed from '@/components/icons/IconFeed';
import IconFull from '@/components/icons/IconFull';
import IconGrid from '@/components/icons/IconGrid';
import {
doesPathOfferSort,
PATH_FEED_INFERRED,
PATH_FULL_INFERRED,
PATH_GRID_INFERRED,
} from '@/app/paths';
import IconSearch from '../components/icons/IconSearch';
@ -26,7 +26,7 @@ import IconSort from '@/components/icons/IconSort';
import { getSortConfigFromPath } from '@/photo/db/sort-path';
import { motion } from 'framer-motion';
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
export type SwitcherSelection = 'full' | 'grid' | 'admin';
const GAP_CLASS = 'mr-1.5 sm:mr-2';
@ -55,7 +55,7 @@ export default function AppViewSwitcher({
sortBy,
isAscending,
pathGrid,
pathFeed,
pathFull,
pathSort,
} = getSortConfigFromPath(pathname);
@ -68,14 +68,14 @@ export default function AppViewSwitcher({
hasLoadedRef.current = true;
}, [invalidateSwr, sortBy]);
const refHrefFeed = useRef<HTMLAnchorElement>(null);
const refHrefFull = useRef<HTMLAnchorElement>(null);
const refHrefGrid = useRef<HTMLAnchorElement>(null);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (!e.metaKey) {
switch (e.key.toLocaleUpperCase()) {
case KEY_COMMANDS.feed:
if (pathname !== PATH_FEED_INFERRED) { refHrefFeed.current?.click(); }
case KEY_COMMANDS.full:
if (pathname !== PATH_FULL_INFERRED) { refHrefFull.current?.click(); }
break;
case KEY_COMMANDS.grid:
if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); }
@ -90,15 +90,15 @@ export default function AppViewSwitcher({
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
const renderItemFeed =
const renderItemFull =
<SwitcherItem
icon={<IconFeed includeTitle={false} />}
href={pathFeed}
hrefRef={refHrefFeed}
active={currentSelection === 'feed'}
icon={<IconFull includeTitle={false} />}
href={pathFull}
hrefRef={refHrefFull}
active={currentSelection === 'full'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: appText.nav.feed,
keyCommand: KEY_COMMANDS.feed,
content: appText.nav.full,
keyCommand: KEY_COMMANDS.full,
}}}
noPadding
/>;
@ -119,8 +119,8 @@ export default function AppViewSwitcher({
return (
<div className={clsx('flex', className)}>
<Switcher className={GAP_CLASS}>
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFeed}
{GRID_HOMEPAGE_ENABLED ? renderItemFeed : renderItemGrid}
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
{GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid}
{/* Show spinner if admin is suspected to be logged in */}
{(isUserSignedInEager && !isUserSignedIn) &&
<SwitcherItem

View File

@ -8,7 +8,7 @@ import AppViewSwitcher, { SwitcherSelection } from '@/app/AppViewSwitcher';
import {
PATH_ROOT,
isPathAdmin,
isPathFeed,
isPathFull,
isPathGrid,
isPathProtected,
isPathSignIn,
@ -58,11 +58,11 @@ export default function Nav({
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === PATH_ROOT) {
return GRID_HOMEPAGE_ENABLED ? 'grid' : 'feed';
return GRID_HOMEPAGE_ENABLED ? 'grid' : 'full';
} else if (isPathGrid(pathname)) {
return 'grid';
} else if (isPathFeed(pathname)) {
return 'feed';
} else if (isPathFull(pathname)) {
return 'full';
} else if (isPathProtected(pathname)) {
return 'admin';
}

View File

@ -3,13 +3,13 @@ import { PhotoSetCategory } from '@/category';
import { getBaseUrl, GRID_HOMEPAGE_ENABLED } from './config';
import { Camera } from '@/camera';
import { parameterize } from '@/utility/string';
import { TAG_HIDDEN } from '@/tag';
import { TAG_PRIVATE } from '@/tag';
import { Lens } from '@/lens';
// Core
export const PATH_ROOT = '/';
export const PATH_GRID = '/grid';
export const PATH_FEED = '/feed';
export const PATH_FULL = '/full';
export const PATH_ADMIN = '/admin';
export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in';
@ -19,8 +19,8 @@ export const PATH_OG = '/og';
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_ROOT
: PATH_GRID;
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_FEED
export const PATH_FULL_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_FULL
: PATH_ROOT;
// Sort
@ -31,7 +31,7 @@ export const PARAM_SORT_ORDER_OLDEST = 'oldest-first';
export const doesPathOfferSort = (pathname: string) =>
pathname === PATH_ROOT ||
pathname.startsWith(PATH_GRID) ||
pathname.startsWith(PATH_FEED);
pathname.startsWith(PATH_FULL);
// Feeds
export const PATH_SITEMAP = '/sitemap.xml';
@ -104,7 +104,7 @@ export const PATHS_ADMIN = [
export const PATHS_TO_CACHE = [
PATH_ROOT,
PATH_GRID,
PATH_FEED,
PATH_FULL,
PATH_OG,
PATH_PHOTO_DYNAMIC,
PATH_CAMERA_DYNAMIC,
@ -154,7 +154,7 @@ export const pathForPhoto = ({
let prefix = PREFIX_PHOTO;
if (typeof photo !== 'string' && photo.hidden) {
prefix = pathForTag(TAG_HIDDEN);
prefix = pathForTag(TAG_PRIVATE);
} else if (recent) {
prefix = PREFIX_RECENTS;
} else if (year) {
@ -231,13 +231,19 @@ export const pathForRecentsImage = () =>
pathForImage(PREFIX_RECENTS);
// Absolute paths
export const ABSOLUTE_PATH_FOR_FEED_JSON =
export const ABSOLUTE_PATH_GRID =
`${getBaseUrl()}${PATH_GRID}`;
export const ABSOLUTE_PATH_FULL =
`${getBaseUrl()}${PATH_FULL}`;
export const ABSOLUTE_PATH_FEED_JSON =
`${getBaseUrl()}${PATH_FEED_JSON}`;
export const ABSOLUTE_PATH_FOR_RSS_XML =
export const ABSOLUTE_PATH_RSS_XML =
`${getBaseUrl()}${PATH_RSS_XML}`;
export const ABSOLUTE_PATH_FOR_HOME_IMAGE =
export const ABSOLUTE_PATH_HOME_IMAGE =
`${getBaseUrl()}/home-image`;
export const absolutePathForPhoto = (
@ -374,13 +380,13 @@ export const isPathRoot = (pathname?: string) =>
export const isPathGrid = (pathname?: string) =>
checkPathPrefix(pathname, PATH_GRID);
export const isPathFeed = (pathname?: string) =>
checkPathPrefix(pathname, PATH_FEED);
export const isPathFull = (pathname?: string) =>
checkPathPrefix(pathname, PATH_FULL);
export const isPathTopLevel = (pathname?: string) =>
isPathRoot(pathname)||
isPathGrid(pathname) ||
isPathFeed(pathname);
isPathFull(pathname);
export const isPathSignIn = (pathname?: string) =>
checkPathPrefix(pathname, PATH_SIGN_IN);
@ -406,7 +412,7 @@ export const isPathAdminInfo = (pathname?: string) =>
export const isPathProtected = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN) ||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)) ||
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
checkPathPrefix(pathname, PATH_OG);
export const getPathComponents = (pathname = ''): {

View File

@ -20,7 +20,7 @@ import {
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_FEED_INFERRED,
PATH_FULL_INFERRED,
PATH_GRID_INFERRED,
PATH_SIGN_IN,
pathForCamera,
@ -50,10 +50,10 @@ import PhotoDate from '@/photo/PhotoDate';
import PhotoSmall from '@/photo/PhotoSmall';
import { FaCheck } from 'react-icons/fa6';
import {
addHiddenToTags,
addPrivateToTags,
formatTag,
isTagFavs,
isTagHidden,
isTagPrivate,
limitTagsByCount,
} from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
@ -84,7 +84,6 @@ import useVisualViewportHeight from '@/utility/useVisualViewport';
import useMaskedScroll from '../components/useMaskedScroll';
import { labelForFilm } from '@/film';
import IconFavs from '@/components/icons/IconFavs';
import IconHidden from '@/components/icons/IconHidden';
import { useAppText } from '@/i18n/state/client';
import LoaderButton from '@/components/primitives/LoaderButton';
import IconRecents from '@/components/icons/IconRecents';
@ -311,12 +310,12 @@ export default function CommandKClient({
, [_years, queryLive]);
const tags = useMemo(() => {
const tagsIncludingHidden = photosCountHidden > 0
? addHiddenToTags(_tags, photosCountHidden)
const tagsIncludingPrivate = photosCountHidden > 0
? addPrivateToTags(_tags, photosCountHidden)
: _tags;
return HIDE_TAGS_WITH_ONE_PHOTO
? limitTagsByCount(tagsIncludingHidden, 2, queryLive)
: tagsIncludingHidden;
? limitTagsByCount(tagsIncludingPrivate, 2, queryLive)
: tagsIncludingPrivate;
}, [_tags, photosCountHidden, queryLive]);
const categorySections: CommandKSection[] = useMemo(() =>
@ -380,10 +379,10 @@ export default function CommandKClient({
className="translate-y-[-0.5px]"
highlight
/>}
{isTagHidden(tag) &&
<IconHidden
size={15}
className="translate-y-[-0.5px]"
{isTagPrivate(tag) &&
<IconLock
size={12}
className="text-dim translate-y-[-0.5px]"
/>}
</span>,
annotation: formatCount(count),
@ -504,11 +503,11 @@ export default function CommandKClient({
});
}
const pageFeed: CommandKItem = {
const pageFull: CommandKItem = {
label: GRID_HOMEPAGE_ENABLED
? appText.nav.feed
: `${appText.nav.feed} (${appText.nav.home})`,
path: PATH_FEED_INFERRED,
? appText.nav.full
: `${appText.nav.full} (${appText.nav.home})`,
path: PATH_FULL_INFERRED,
};
const pageGrid: CommandKItem = {
@ -519,8 +518,8 @@ export default function CommandKClient({
};
const pageItems: CommandKItem[] = GRID_HOMEPAGE_ENABLED
? [pageGrid, pageFeed]
: [pageFeed, pageGrid];
? [pageGrid, pageFull]
: [pageFull, pageGrid];
const sectionPages: CommandKSection = {
heading: 'Pages',

View File

@ -2,10 +2,13 @@ import clsx from 'clsx/lite';
import { InputHTMLAttributes, ReactNode, RefObject } from 'react';
import { ImCheckmark } from 'react-icons/im';
const SIZE = 'size-4.5';
const boxStyles = clsx(
'relative',
'inline-flex items-center justify-center',
'size-5 rounded-md border',
'rounded-md border',
SIZE,
);
export default function Checkbox({
@ -22,7 +25,7 @@ export default function Checkbox({
<span
className={clsx(
'relative inline-flex items-center justify-center',
'size-5',
SIZE,
props.readOnly
? 'cursor-not-allowed'
: 'group-has-active:opacity-70',
@ -40,7 +43,7 @@ export default function Checkbox({
: 'bg-black',
)}>
<ImCheckmark
size={12}
size={11}
className={clsx(
'text-white',
props.readOnly && 'dark:text-gray-400',

View File

@ -10,6 +10,7 @@ import { FiChevronDown } from 'react-icons/fi';
import { parameterize } from '@/utility/string';
import Checkbox from './Checkbox';
import ResponsiveText from './primitives/ResponsiveText';
import Tooltip from './Tooltip';
export default function FieldSetWithStatus({
id: _id,
@ -17,6 +18,7 @@ export default function FieldSetWithStatus({
icon,
note,
noteShort,
tooltip,
error,
value,
isModified,
@ -45,6 +47,7 @@ export default function FieldSetWithStatus({
icon?: ReactNode
note?: string
noteShort?: string
tooltip?: string
error?: string
value: string
isModified?: boolean
@ -116,7 +119,7 @@ export default function FieldSetWithStatus({
// For managing checkbox active state
'group',
'space-y-1',
type === 'checkbox' && 'flex items-center gap-3',
type === 'checkbox' && 'flex items-center gap-2',
className,
)}>
{!hideLabel &&
@ -124,16 +127,27 @@ export default function FieldSetWithStatus({
htmlFor={id}
className={clsx(
'inline-flex flex-wrap gap-x-2 items-center select-none',
type === 'checkbox' && 'order-2 m-0',
type === 'checkbox' && 'order-2 m-0 translate-y-[0.25px]',
type === 'checkbox' && readOnly &&
'opacity-50 cursor-not-allowed',
)}
>
<span className="inline-flex items-center gap-x-1.5">
{icon && <span
className="inline-flex items-center justify-center w-4"
>
{icon}
</span>}
{label}
<span className="inline-flex items-center gap-x-[5px]">
{icon &&
<span className={clsx(
'inline-flex items-center justify-center w-4 shrink-0',
)}>
{icon}
</span>}
<span className="truncate">
{label}
</span>
{tooltip &&
<Tooltip
content={tooltip}
classNameTrigger="translate-y-[-1.5px] text-dim"
supportMobile
/>}
</span>
{note && !error &&
<ResponsiveText

View File

@ -110,8 +110,10 @@ export default function EntityLink({
setIsLoading={setIsLoading}
>
<LabeledIcon {...{
icon: (hasBadgeIcon && !useForHover) ? undefined : icon,
iconWide: (hasBadgeIcon && !useForHover) ? undefined : iconWide,
icon:
(badged && hasBadgeIcon && !useForHover) ? undefined : icon,
iconWide:
(badged && hasBadgeIcon && !useForHover) ? undefined : iconWide,
prefetch,
title,
type: useForHover ? 'icon-first' : type,

View File

@ -3,7 +3,7 @@
const INTRINSIC_WIDTH = 28;
const INTRINSIC_HEIGHT = 24;
export default function IconFeed({
export default function IconFull({
width = INTRINSIC_WIDTH,
includeTitle = true,
className,

View File

@ -1,3 +1,4 @@
import clsx from 'clsx/lite';
import { IconBaseProps } from 'react-icons';
import { AiOutlineEyeInvisible, AiOutlineEye } from 'react-icons/ai';
@ -7,6 +8,8 @@ export default function IconHidden({
}: IconBaseProps & {
visible?: boolean
}) {
// Flip so slash goes left to right
props.className = clsx('-scale-x-100', props.className);
return visible
? <AiOutlineEye {...props} />
: <AiOutlineEyeInvisible {...props} />;

View File

@ -1,12 +1,19 @@
import { IconBaseProps } from 'react-icons';
import { BiLockAlt } from 'react-icons/bi';
import { BiLockAlt, BiLockOpenAlt } from 'react-icons/bi';
import { FaLock, FaLockOpen } from 'react-icons/fa';
import { FiLock } from 'react-icons/fi';
export default function IconLock({
solid,
narrow,
open,
...props
}: IconBaseProps & { narrow?: boolean }) {
return narrow
? <BiLockAlt {...props} />
: <FiLock {...props} />;
}: IconBaseProps & { solid?: boolean, narrow?: boolean, open?: boolean }) {
if (solid) {
return open ? <FaLockOpen {...props} /> : <FaLock {...props} />;
} else if (narrow) {
return open ? <BiLockOpenAlt {...props} /> : <BiLockAlt {...props} />;
} else {
return open ? <FiLock {...props} /> : <FiLock {...props} />;
}
}

View File

@ -1,32 +1,43 @@
import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { PhotoQueryOptions } from '../photo/db';
import {
getNextImageUrlForRequest,
NextImageSize,
} from '@/platforms/next-image';
INFINITE_SCROLL_FULL_INITIAL,
INFINITE_SCROLL_GRID_INITIAL,
} from '../photo';
import { SortBy } from '../photo/db/sort';
import { FEED_PHOTO_REQUEST_LIMIT } from './programmatic';
export const FEED_PHOTO_REQUEST_LIMIT = 40;
const FEED_BASE_QUERY_OPTIONS: PhotoQueryOptions = {
excludeFromFeeds: true,
};
export const FEED_PHOTO_WIDTH_SMALL = 200;
export const FEED_PHOTO_WIDTH_MEDIUM = 640;
export const FEED_PHOTO_WIDTH_LARGE = 1200;
// PAGE FEED QUERY OPTIONS
export interface FeedMedia {
url: string
width: number
height: number
}
export const generateFeedMedia = (
photo: Photo,
size: NextImageSize,
): FeedMedia => ({
url: getNextImageUrlForRequest({ imageUrl: photo.url, size }),
width: size,
height: Math.round(size / photo.aspectRatio),
export const getFeedQueryOptions = ({
isGrid,
sortBy = USER_DEFAULT_SORT_OPTIONS.sortBy,
sortWithPriority = USER_DEFAULT_SORT_OPTIONS.sortWithPriority,
}: {
isGrid: boolean,
sortBy?: SortBy,
sortWithPriority?: boolean,
}): PhotoQueryOptions => ({
...FEED_BASE_QUERY_OPTIONS,
sortBy,
sortWithPriority,
limit: isGrid
? INFINITE_SCROLL_GRID_INITIAL
: INFINITE_SCROLL_FULL_INITIAL,
});
export const getCoreFeedFields = (photo: Photo) => ({
id: photo.id,
title: titleForPhoto(photo),
description: descriptionForPhoto(photo, true),
});
export const FEED_META_QUERY_OPTIONS: PhotoQueryOptions = {
...FEED_BASE_QUERY_OPTIONS,
};
// PROGRAMMATIC FEED QUERY OPTIONS
export const PROGRAMMATIC_QUERY_OPTIONS: PhotoQueryOptions = {
...FEED_BASE_QUERY_OPTIONS,
sortBy: 'createdAt',
limit: FEED_PHOTO_REQUEST_LIMIT,
};

View File

@ -6,7 +6,7 @@ import {
FeedMedia,
generateFeedMedia,
getCoreFeedFields,
} from '.';
} from './programmatic';
import { formatDateFromPostgresString } from '@/utility/date';
import { Photo } from '@/photo';
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';

32
src/feed/programmatic.ts Normal file
View File

@ -0,0 +1,32 @@
import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo';
import {
getNextImageUrlForRequest,
NextImageSize,
} from '@/platforms/next-image';
export const FEED_PHOTO_REQUEST_LIMIT = 40;
export const FEED_PHOTO_WIDTH_SMALL = 200;
export const FEED_PHOTO_WIDTH_MEDIUM = 640;
export const FEED_PHOTO_WIDTH_LARGE = 1200;
export interface FeedMedia {
url: string
width: number
height: number
}
export const generateFeedMedia = (
photo: Photo,
size: NextImageSize,
): FeedMedia => ({
url: getNextImageUrlForRequest({ imageUrl: photo.url, size }),
width: size,
height: Math.round(size / photo.aspectRatio),
});
export const getCoreFeedFields = (photo: Photo) => ({
id: photo.id,
title: titleForPhoto(photo),
description: descriptionForPhoto(photo, true),
});

View File

@ -5,8 +5,8 @@ import {
FeedMedia,
generateFeedMedia,
getCoreFeedFields,
} from '.';
import { ABSOLUTE_PATH_FOR_RSS_XML, absolutePathForPhoto } from '@/app/paths';
} from './programmatic';
import { ABSOLUTE_PATH_RSS_XML, absolutePathForPhoto } from '@/app/paths';
import { formatDate } from '@/utility/date';
import { formatStringForXml } from '@/utility/string';
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
@ -67,7 +67,7 @@ export const formatFeedRssXml = (photos: Photo[]) =>
<channel>
<title>${META_TITLE}</title>
<atom:link
href="${ABSOLUTE_PATH_FOR_RSS_XML}"
href="${ABSOLUTE_PATH_RSS_XML}"
rel="self"
type="application/rss+xml"
/>

View File

@ -44,7 +44,7 @@ export const TEXT: I18N = {
},
nav: {
home: 'হোম',
feed: 'ফিড',
full: 'সম্পূর্ণ',
grid: 'গ্রিড',
admin: 'অ্যাডমিন',
search: 'সার্চ',
@ -103,8 +103,8 @@ export const TEXT: I18N = {
edit: 'এডিট',
favorite: 'পছন্দ',
unfavorite: 'পছন্দ অপসারণ',
hide: 'লুকান',
unhide: 'দেখান',
private: 'ব্যক্তিগত করুন',
public: 'সর্বজনীন করুন',
download: 'ডাউনলোড',
sync: 'সিঙ্ক',
delete: 'ডিলিট',

View File

@ -42,7 +42,7 @@ export const TEXT = {
},
nav: {
home: 'Home',
feed: 'Feed',
full: 'Full',
grid: 'Grid',
admin: 'Admin',
search: 'Search',
@ -101,8 +101,8 @@ export const TEXT = {
edit: 'Edit',
favorite: 'Favorite',
unfavorite: 'Unfavorite',
hide: 'Hide',
unhide: 'Unhide',
private: 'Make Private',
public: 'Make Public',
download: 'Download',
sync: 'Sync',
delete: 'Delete',

View File

@ -43,7 +43,7 @@ export const TEXT: I18N = {
},
nav: {
home: 'Beranda',
feed: 'Umpan',
full: 'Lengkap',
grid: 'Grid',
admin: 'Admin',
search: 'Cari',
@ -102,8 +102,8 @@ export const TEXT: I18N = {
edit: 'Edit',
favorite: 'Favorit',
unfavorite: 'Hapus dari Favorit',
hide: 'Sembunyikan',
unhide: 'Tampilkan',
private: 'Buat Privat',
public: 'Buat Publik',
download: 'Unduh',
sync: 'Sinkronkan',
delete: 'Hapus',

View File

@ -43,7 +43,7 @@ export const TEXT: I18N = {
},
nav: {
home: 'Início',
feed: 'Feed',
full: 'Completo',
grid: 'Grade',
admin: 'Menu de administrador',
search: 'Pesquisar',
@ -102,8 +102,8 @@ export const TEXT: I18N = {
edit: 'Editar',
favorite: 'Favoritar',
unfavorite: 'Remover dos favoritos',
hide: 'Ocultar',
unhide: 'Mostrar',
private: 'Tornar Privado',
public: 'Tornar Público',
download: 'Baixar',
sync: 'Sincronizar',
delete: 'Excluir',

View File

@ -43,7 +43,7 @@ export const TEXT: I18N = {
},
nav: {
home: 'Início',
feed: 'Feed',
full: 'Completo',
grid: 'Grade',
admin: 'Menu de administração',
search: 'Pesquisar',
@ -102,8 +102,8 @@ export const TEXT: I18N = {
edit: 'Editar',
favorite: 'Favoritar',
unfavorite: 'Remover dos favoritos',
hide: 'Ocultar',
unhide: 'Mostrar',
private: 'Tornar Privado',
public: 'Tornar Público',
download: 'Descarregar',
sync: 'Sincronizar',
delete: 'Excluir',

View File

@ -43,7 +43,7 @@ export const TEXT: I18N = {
},
nav: {
home: '首页',
feed: '动态',
full: '完整',
grid: '网格',
admin: '管理',
search: '搜索',
@ -102,8 +102,8 @@ export const TEXT: I18N = {
edit: '编辑',
favorite: '收藏',
unfavorite: '取消收藏',
hide: '隐藏',
unhide: '取消隐藏',
private: '设为私密',
public: '设为公开',
download: '下载',
sync: '同步',
delete: '删除',

View File

@ -1,5 +1,5 @@
import { Photo } from '../photo';
import IconFeed from '@/components/icons/IconFeed';
import IconFull from '@/components/icons/IconFull';
import IconGrid from '@/components/icons/IconGrid';
import ImagePhotoGrid from './components/ImagePhotoGrid';
import { NextImageSize } from '@/platforms/next-image';
@ -66,7 +66,7 @@ export default function TemplateImageResponse({
color: '#333',
borderRight: '2px solid #333',
}}>
<IconFeed includeTitle={false} width={80} />
<IconFull includeTitle={false} width={80} />
</div>
<div style={{
display: 'flex',

View File

@ -35,6 +35,7 @@ export default function InfinitePhotoScroll({
itemsPerPage,
sortBy,
sortWithPriority,
excludeFromFeeds,
camera,
lens,
tag,
@ -50,6 +51,7 @@ export default function InfinitePhotoScroll({
itemsPerPage: number
sortBy?: SortBy
sortWithPriority?: boolean
excludeFromFeeds?: boolean
cacheKey: string
wrapMoreButtonInGrid?: boolean
useCachedPhotos?: boolean
@ -77,6 +79,7 @@ export default function InfinitePhotoScroll({
offset: initialOffset + getSizeFromKey(keyWithSize) * itemsPerPage,
sortBy,
sortWithPriority,
excludeFromFeeds,
limit: itemsPerPage,
hidden: includeHiddenPhotos ? 'include' : 'exclude',
camera,
@ -90,6 +93,7 @@ export default function InfinitePhotoScroll({
useCachedPhotos,
sortBy,
sortWithPriority,
excludeFromFeeds,
initialOffset,
itemsPerPage,
includeHiddenPhotos,

View File

@ -7,8 +7,8 @@ import PhotoGrid from './PhotoGrid';
import TagHeader from '@/tag/TagHeader';
import CameraHeader from '@/camera/CameraHeader';
import FilmHeader from '@/film/FilmHeader';
import { TAG_HIDDEN } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader';
import { TAG_PRIVATE } from '@/tag';
import PrivateHeader from '@/tag/PrivateHeader';
import FocalLengthHeader from '@/focal/FocalLengthHeader';
import PhotoHeader from './PhotoHeader';
import RecipeHeader from '@/recipe/RecipeHeader';
@ -48,8 +48,8 @@ export default function PhotoDetailPage({
let customHeader: ReactNode | undefined;
if (tag) {
customHeader = tag === TAG_HIDDEN
? <HiddenHeader
customHeader = tag === TAG_PRIVATE
? <PrivateHeader
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}

View File

@ -1,12 +1,12 @@
import {
INFINITE_SCROLL_FEED_MULTIPLE,
INFINITE_SCROLL_FULL_MULTIPLE,
Photo,
} from '.';
import PhotosLarge from './PhotosLarge';
import PhotosLargeInfinite from './PhotosLargeInfinite';
import { SortBy } from './db/sort';
export default function PhotoFeedPage({
export default function PhotoFullPage({
photos,
photosCount,
sortBy,
@ -25,7 +25,7 @@ export default function PhotoFeedPage({
sortBy={sortBy}
sortWithPriority={sortWithPriority}
initialOffset={photos.length}
itemsPerPage={INFINITE_SCROLL_FEED_MULTIPLE}
itemsPerPage={INFINITE_SCROLL_FULL_MULTIPLE}
/>}
</div>
);

View File

@ -15,6 +15,7 @@ export default function PhotoGridContainer({
count,
sortBy,
sortWithPriority,
excludeFromFeeds,
animateOnFirstLoadOnly,
header,
sidebar,
@ -25,6 +26,7 @@ export default function PhotoGridContainer({
count: number
sortBy?: SortBy
sortWithPriority?: boolean
excludeFromFeeds?: boolean
header?: ReactNode
sidebar?: ReactNode
} & ComponentProps<typeof PhotoGrid>) {
@ -61,6 +63,7 @@ export default function PhotoGridContainer({
initialOffset: photos.length,
sortBy,
sortWithPriority,
excludeFromFeeds,
...categories,
canStart: shouldAnimateDynamicItems,
animateOnFirstLoadOnly,

View File

@ -11,6 +11,7 @@ export default function PhotoGridInfinite({
initialOffset,
sortBy,
sortWithPriority,
excludeFromFeeds,
canStart,
animateOnFirstLoadOnly,
canSelect,
@ -20,6 +21,7 @@ export default function PhotoGridInfinite({
initialOffset: number
sortBy?: SortBy
sortWithPriority?: boolean
excludeFromFeeds?: boolean
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
return (
<InfinitePhotoScroll
@ -28,6 +30,7 @@ export default function PhotoGridInfinite({
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
excludeFromFeeds={excludeFromFeeds}
{...categories}
>
{({ photos, onLastPhotoVisible }) =>

View File

@ -42,6 +42,7 @@ export default function PhotoGridPageClient({
count={photosCount}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
excludeFromFeeds
prioritizeInitialPhotos
sidebar={
<MaskedScroll

View File

@ -4,12 +4,17 @@ import PhotoCamera from '@/camera/PhotoCamera';
import HeaderList from '@/components/HeaderList';
import PhotoTag from '@/tag/PhotoTag';
import { photoQuantityText } from '.';
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
import {
TAG_FAVS,
TAG_PRIVATE,
addPrivateToTags,
limitTagsByCount,
} from '@/tag';
import PhotoFilm from '@/film/PhotoFilm';
import FavsTag from '../tag/FavsTag';
import PhotoFavs from '../tag/PhotoFavs';
import { useAppState } from '@/app/AppState';
import { useMemo, useRef } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import PhotoPrivate from '@/tag/PhotoPrivate';
import {
CATEGORY_VISIBILITY,
HIDE_TAGS_WITH_ONE_PHOTO,
@ -96,7 +101,7 @@ export default function PhotoGridSidebar({
const { photosCountHidden } = useAppState();
const tagsIncludingHidden = useMemo(() =>
addHiddenToTags(tags, photosCountHidden)
addPrivateToTags(tags, photosCountHidden)
, [tags, photosCountHidden]);
const recentsContent = recents.length > 0
@ -196,7 +201,7 @@ export default function PhotoGridSidebar({
.map(({ tag, count }) => {
switch (tag) {
case TAG_FAVS:
return <FavsTag
return <PhotoFavs
key={TAG_FAVS}
countOnHover={count}
type="icon-last"
@ -204,9 +209,9 @@ export default function PhotoGridSidebar({
contrast="low"
badged
/>;
case TAG_HIDDEN:
return <HiddenTag
key={TAG_HIDDEN}
case TAG_PRIVATE:
return <PhotoPrivate
key={TAG_PRIVATE}
countOnHover={count}
type="icon-last"
prefetch={false}

View File

@ -20,7 +20,7 @@ import {
deletePhotoAction,
syncPhotoAction,
toggleFavoritePhotoAction,
toggleHidePhotoAction,
togglePrivatePhotoAction,
} from './actions';
import { isPhotoFav } from '@/tag';
import Tooltip from '@/components/Tooltip';
@ -68,7 +68,7 @@ export default function PhotoPrevNextActions({
}, [photo?.id]);
const toggleHidden = useCallback(() => {
if (photo?.id) { return toggleHidePhotoAction(photo.id); }
if (photo?.id) { return togglePrivatePhotoAction(photo.id); }
}, [photo?.id]);
const navigateToPhotoEdit = useNavigateOrRunActionWithToast({
@ -168,7 +168,7 @@ export default function PhotoPrevNextActions({
unfavoritePhoto();
}
break;
case KEY_COMMANDS.toggleHide:
case KEY_COMMANDS.togglePrivate:
if (isUserSignedIn && photo) {
if (photo.hidden) {
unhidePhoto();

View File

@ -1,6 +1,6 @@
'use client';
import { PATH_FEED_INFERRED } from '@/app/paths';
import { PATH_FULL_INFERRED } from '@/app/paths';
import InfinitePhotoScroll from './InfinitePhotoScroll';
import PhotosLarge from './PhotosLarge';
import { SortBy } from './db/sort';
@ -17,7 +17,7 @@ export default function PhotosLargeInfinite({
}) {
return (
<InfinitePhotoScroll
cacheKey={`page-${PATH_FEED_INFERRED}`}
cacheKey={`page-${PATH_FULL_INFERRED}`}
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
sortBy={sortBy}

View File

@ -94,6 +94,7 @@ const addUpload = async ({
tags,
favorite,
hidden,
excludeFromFeeds,
takenAtLocal,
takenAtNaiveLocal,
onStreamUpdate,
@ -104,6 +105,7 @@ const addUpload = async ({
tags?: string
favorite?: string
hidden?: string
excludeFromFeeds?: string
takenAtLocal: string
takenAtNaiveLocal: string
onStreamUpdate?: (
@ -147,6 +149,7 @@ const addUpload = async ({
title: title || aiTitle,
caption,
tags: tags || aiTags,
excludeFromFeeds,
hidden,
favorite,
semanticDescription,
@ -187,6 +190,7 @@ export const addUploadsAction = async ({
tags,
favorite,
hidden,
excludeFromFeeds,
takenAtLocal,
takenAtNaiveLocal,
}: Omit<
@ -231,6 +235,7 @@ export const addUploadsAction = async ({
tags,
favorite,
hidden,
excludeFromFeeds,
takenAtLocal,
takenAtNaiveLocal,
onStreamUpdate: streamUpdate,
@ -317,7 +322,7 @@ export const toggleFavoritePhotoAction = async (
}
});
export const toggleHidePhotoAction = async (
export const togglePrivatePhotoAction = async (
photoId: string,
redirectPath?: string,
) =>

View File

@ -25,7 +25,7 @@ import {
PATHS_ADMIN,
PATHS_TO_CACHE,
PATH_ADMIN,
PATH_FEED,
PATH_FULL,
PATH_GRID,
PATH_ROOT,
PREFIX_CAMERA,
@ -153,7 +153,7 @@ export const revalidatePhoto = (photoId: string) => {
revalidatePath(pathForPhoto({ photo: photoId }), 'layout');
revalidatePath(PATH_ROOT, 'layout');
revalidatePath(PATH_GRID, 'layout');
revalidatePath(PATH_FEED, 'layout');
revalidatePath(PATH_FULL, 'layout');
revalidatePath(PREFIX_TAG, 'layout');
revalidatePath(PREFIX_CAMERA, 'layout');
revalidatePath(PREFIX_LENS, 'layout');
@ -179,18 +179,23 @@ export const getPhotosNearIdCached = (
getPhotosNearId,
[KEY_PHOTOS, ...getPhotosCacheKeys(args[1])],
)(...args).then(({ photos, indexNumber }) => {
const [photoId, { limit }] = args;
const [photoId, { limit }, excludeFromFeeds] = args;
const photo = photos.find(({ id }) => id === photoId);
const isPhotoFirst = photos.findIndex(p => p.id === photoId) === 0;
return {
photo: photo ? parseCachedPhotoDates(photo) : undefined,
photos: parseCachedPhotosDates(photos),
...limit && {
photosGrid: photos.slice(
isPhotoFirst ? 1 : 2,
isPhotoFirst ? limit - 1 : limit,
),
},
// Don't show photo in context when excluded from feeds
...excludeFromFeeds && photo?.excludeFromFeeds
? { photos: [] }
: {
photos: parseCachedPhotosDates(photos),
...limit && {
photosGrid: photos.slice(
isPhotoFirst ? 1 : 2,
isPhotoFirst ? limit - 1 : limit,
),
},
},
indexNumber,
};
});

View File

@ -29,6 +29,7 @@ export type PhotoQueryOptions = {
takenBefore?: Date
takenAfterInclusive?: Date
updatedBefore?: Date
excludeFromFeeds?: boolean
hidden?: 'exclude' | 'include' | 'only'
} & Omit<PhotoSetCategory, 'camera' | 'lens'> & {
camera?: Partial<Camera>
@ -44,6 +45,7 @@ export const getWheresFromOptions = (
) => {
const {
hidden = 'exclude',
excludeFromFeeds,
takenBefore,
takenAfterInclusive,
updatedBefore,
@ -72,6 +74,9 @@ export const getWheresFromOptions = (
break;
}
if (excludeFromFeeds) {
wheres.push('exclude_from_feeds IS NOT TRUE');
}
if (takenBefore) {
wheres.push(`taken_at < $${valuesIndex++}`);
wheresValues.push(takenBefore.toISOString());

View File

@ -71,6 +71,13 @@ export const MIGRATIONS: Migration[] = [{
END IF;
END $$;
`,
}, {
label: '06: Exclude from feeds',
fields: ['exclude_from_feeds'],
run: () => sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS exclude_from_feeds BOOLEAN DEFAULT FALSE
`,
}];
export const migrationForError = (e: any) =>

View File

@ -69,7 +69,8 @@ const createPhotosTable = () =>
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
hidden BOOLEAN,
exclude_from_feeds BOOLEAN DEFAULT FALSE,
hidden BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
@ -193,6 +194,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
recipe_title,
recipe_data,
priority_order,
exclude_from_feeds,
hidden,
taken_at,
taken_at_naive
@ -224,6 +226,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
${photo.recipeTitle},
${photo.recipeData},
${photo.priorityOrder},
${photo.excludeFromFeeds},
${photo.hidden},
${photo.takenAt},
${photo.takenAtNaive}
@ -258,6 +261,7 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
recipe_title=${photo.recipeTitle},
recipe_data=${photo.recipeData},
priority_order=${photo.priorityOrder || null},
exclude_from_feeds=${photo.excludeFromFeeds},
hidden=${photo.hidden},
taken_at=${photo.takenAt},
taken_at_naive=${photo.takenAtNaive},
@ -527,6 +531,7 @@ export const getPhotos = async (options: PhotoQueryOptions = {}) =>
export const getPhotosNearId = async (
photoId: string,
options: PhotoQueryOptions,
excludeFromFeeds?: boolean,
) =>
safelyQueryPhotos(async () => {
const { limit } = options;
@ -561,6 +566,7 @@ export const getPhotosNearId = async (
return {
photos: rows.map(parsePhotoFromDb),
indexNumber,
excludeFromFeeds,
};
});
}, `getPhotosNearId: ${photoId}`);

View File

@ -6,8 +6,8 @@ import {
PARAM_SORT_ORDER_OLDEST,
PARAM_SORT_TYPE_TAKEN_AT,
PARAM_SORT_TYPE_UPLOADED_AT,
PATH_FEED,
PATH_FEED_INFERRED,
PATH_FULL,
PATH_FULL_INFERRED,
PATH_GRID,
PATH_GRID_INFERRED,
} from '@/app/paths';
@ -84,11 +84,11 @@ export const getSortOptionsFromParams = async (
};
export const getPathSortComponents = (pathname: string) => {
const [_, gridOrFeed, sortType, sortOrder] = pathname.split('/');
const [_, gridOrFull, sortType, sortOrder] = pathname.split('/');
return {
gridOrFeed: gridOrFeed || (GRID_HOMEPAGE_ENABLED
gridOrFull: gridOrFull || (GRID_HOMEPAGE_ENABLED
? 'grid'
: 'feed'
: 'full'
),
sortType: sortType || DEFAULT_SORT_TYPE,
sortOrder: sortOrder || DEFAULT_SORT_ORDER,
@ -101,7 +101,7 @@ const getReversedSortOrder = (sortOrder: string): string =>
: PARAM_SORT_ORDER_OLDEST;
export const getSortConfigFromPath = (pathname: string) => {
const { gridOrFeed, sortType, sortOrder } = getPathSortComponents(pathname);
const { gridOrFull, sortType, sortOrder } = getPathSortComponents(pathname);
const { sortBy } = _getSortOptionsFromParams(sortType, sortOrder);
const isSortedByDefault = sortBy === USER_DEFAULT_SORT_BY;
const reversedSortOrder = getReversedSortOrder(sortOrder);
@ -116,13 +116,13 @@ export const getSortConfigFromPath = (pathname: string) => {
pathGrid: isSortedByDefault
? PATH_GRID_INFERRED
: `${PATH_GRID}/${sortType}/${sortOrder}`,
pathFeed: isSortedByDefault
? PATH_FEED_INFERRED
: `${PATH_FEED}/${sortType}/${sortOrder}`,
pathFull: isSortedByDefault
? PATH_FULL_INFERRED
: `${PATH_FULL}/${sortType}/${sortOrder}`,
pathSort: doesReverseSortMatchDefault
? gridOrFeed === 'grid'
? gridOrFull === 'grid'
? PATH_GRID_INFERRED
: PATH_FEED_INFERRED
: `/${gridOrFeed}/${sortType}/${reversedSortOrder}`,
: PATH_FULL_INFERRED
: `/${gridOrFull}/${sortType}/${reversedSortOrder}`,
};
};

View File

@ -2,16 +2,21 @@ import { ComponentProps } from 'react';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import IconHidden from '@/components/icons/IconHidden';
export default function FieldsetHidden(props: Omit<
export default function FieldsetExclude(props: Omit<
ComponentProps<typeof FieldSetWithStatus>,
'label' | 'icon' | 'type'
>) {
return (
<FieldSetWithStatus
{...props}
label="Hidden"
label="Exclude from feeds"
type="checkbox"
icon={<IconHidden size={17} visible={props.value !== 'true'} />}
icon={<IconHidden
size={17}
className="translate-y-[0.5px]"
visible={props.value !== 'true'}
/>}
tooltip="Do not show on homepage views or RSS"
/>
);
}

View File

@ -0,0 +1,22 @@
import { ComponentProps } from 'react';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import IconLock from '@/components/icons/IconLock';
export default function FieldsetPrivate(props: Omit<
ComponentProps<typeof FieldSetWithStatus>,
'label' | 'icon' | 'type'
>) {
return (
<FieldSetWithStatus
{...props}
label="Private"
type="checkbox"
icon={<IconLock
size={15}
open={props.value !== 'true'}
narrow
/>}
tooltip="Visible only to authenticated admin"
/>
);
}

View File

@ -45,9 +45,10 @@ import { convertFilmsForForm, Films } from '@/film';
import { isMakeFujifilm } from '@/platforms/fujifilm';
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
import FieldsetFavs from './FieldsetFavs';
import FieldsetHidden from './FieldsetHidden';
import FieldsetPrivate from './FieldsetPrivate';
import { useAppText } from '@/i18n/state/client';
import IconAddUpload from '@/components/icons/IconAddUpload';
import FieldsetExclude from './FieldsetExclude';
const THUMBNAIL_SIZE = 300;
@ -184,6 +185,16 @@ export default function PhotoForm({
onTextContentChange?.(formHasTextContent(formData));
}, [onTextContentChange, formData]);
useEffect(() => {
if (formData.hidden === 'true') {
setFormData(data => ({
...data,
excludeFromFeeds: 'false',
favorite: 'false',
}));
}
}, [formData.hidden]);
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
switch (key) {
case 'title':
@ -241,7 +252,7 @@ export default function PhotoForm({
}
};
const shouldHideField = (
const isFieldHidden = (
key: FormFields,
hideIfEmpty?: boolean,
shouldHide?: FormMeta['shouldHide'],
@ -261,6 +272,13 @@ export default function PhotoForm({
}
};
const isFieldReadOnly = (key: FormFields) => {
return formData.hidden === 'true' && (
key === 'excludeFromFeeds' ||
key === 'favorite'
);
};
const onMatchResults = useCallback((didFindMatchingPhotos: boolean) => {
setFormData(data => ({
...data,
@ -361,7 +379,7 @@ export default function PhotoForm({
type,
staticValue,
}]) => {
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
id: key,
label: label + (
@ -403,7 +421,7 @@ export default function PhotoForm({
tagOptionsLimit,
tagOptionsLimitValidationMessage,
required,
readOnly,
readOnly: readOnly || isFieldReadOnly(key),
spellCheck,
capitalize,
placeholder: loadingMessage && !formData[key]
@ -445,8 +463,13 @@ export default function PhotoForm({
key={key}
{...fieldProps}
/>;
case 'excludeFromFeeds':
return <FieldsetExclude
key={key}
{...fieldProps}
/>;
case 'hidden':
return <FieldsetHidden
return <FieldsetPrivate
key={key}
{...fieldProps}
/>;
@ -462,7 +485,7 @@ export default function PhotoForm({
{/* Actions */}
<div className={clsx(
'flex gap-3 sticky bottom-0',
'pb-4 md:pb-8 mt-12',
'pb-4 md:pb-8 mt-16',
)}>
<Link
className="button"

View File

@ -182,6 +182,7 @@ const FORM_METADATA = (
},
priorityOrder: { label: 'priority order' },
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
excludeFromFeeds: { label: 'exclude from feeds', type: 'checkbox' },
hidden: { label: 'hidden', type: 'checkbox' },
shouldStripGpsData: {
label: 'strip gps data',
@ -338,6 +339,7 @@ export const convertFormDataToPhotoDbInsert = (
priorityOrder: photoForm.priorityOrder
? parseFloat(photoForm.priorityOrder)
: undefined,
excludeFromFeeds: photoForm.excludeFromFeeds === 'true',
hidden: photoForm.hidden === 'true',
...generateTakenAtFields(photoForm),
};

View File

@ -8,7 +8,7 @@ import {
SHOW_LENSES,
SHOW_RECIPES,
} from '@/app/config';
import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/app/paths';
import { ABSOLUTE_PATH_HOME_IMAGE } from '@/app/paths';
import { formatDate, formatDateFromPostgresString } from '@/utility/date';
import {
formatAperture,
@ -25,10 +25,10 @@ import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync';
import { AppTextState } from '@/i18n/state';
// INFINITE SCROLL: FEED
export const INFINITE_SCROLL_FEED_INITIAL =
// INFINITE SCROLL: FULL
export const INFINITE_SCROLL_FULL_INITIAL =
process.env.NODE_ENV === 'development' ? 2 : 12;
export const INFINITE_SCROLL_FEED_MULTIPLE =
export const INFINITE_SCROLL_FULL_MULTIPLE =
process.env.NODE_ENV === 'development' ? 2 : 24;
// INFINITE SCROLL: GRID
@ -84,6 +84,7 @@ export interface PhotoDbInsert extends PhotoExif {
recipeTitle?: string
locationName?: string
priorityOrder?: number
excludeFromFeeds?: boolean
hidden?: boolean
takenAt: string
takenAtNaive: string
@ -198,11 +199,11 @@ export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => {
if (photos.length > 0) {
return {
openGraph: {
images: ABSOLUTE_PATH_FOR_HOME_IMAGE,
images: ABSOLUTE_PATH_HOME_IMAGE,
},
twitter: {
card: 'summary_large_image',
images: ABSOLUTE_PATH_FOR_HOME_IMAGE,
images: ABSOLUTE_PATH_HOME_IMAGE,
},
};
} else {

View File

@ -1,5 +1,5 @@
export const KEY_COMMANDS = {
feed: 'F',
full: 'F',
grid: 'G',
admin: 'A',
prev: ['J', 'ARROWLEFT'],
@ -7,7 +7,7 @@ export const KEY_COMMANDS = {
edit: 'E',
favorite: 'P',
unfavorite: 'X',
toggleHide: 'H',
togglePrivate: 'M',
download: 'D',
sync: 'S',
search: ['⌘', 'K'],

View File

@ -1,21 +0,0 @@
import { TAG_HIDDEN } from '.';
import { pathForTag } from '@/app/paths';
import IconHidden from '@/components/icons/IconHidden';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/entity/EntityLink';
export default function HiddenTag(props: EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={TAG_HIDDEN}
path={pathForTag(TAG_HIDDEN)}
icon={<IconHidden size={16} />}
iconBadgeEnd={<IconHidden
size={13}
className="translate-y-[-0.5px]"
/>}
/>
);
}

View File

@ -7,7 +7,7 @@ import EntityLink, {
} from '@/components/entity/EntityLink';
import IconFavs from '@/components/icons/IconFavs';
export default function FavsTag(props: EntityLinkExternalProps) {
export default function PhotoFavs(props: EntityLinkExternalProps) {
return (
<EntityLink
{...props}

26
src/tag/PhotoPrivate.tsx Normal file
View File

@ -0,0 +1,26 @@
import { TAG_PRIVATE } from '.';
import { pathForTag } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/entity/EntityLink';
import IconLock from '@/components/icons/IconLock';
export default function PhotoPrivate(props: EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={TAG_PRIVATE}
path={pathForTag(TAG_PRIVATE)}
icon={<IconLock
size={15}
className="translate-y-[-0.5px]"
narrow
/>}
iconBadgeEnd={<IconLock
size={8}
className="translate-y-[-0.5px]"
solid
/>}
/>
);
}

View File

@ -1,6 +1,6 @@
import PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.';
import FavsTag from './FavsTag';
import PhotoFavs from './PhotoFavs';
import { EntityLinkExternalProps } from '@/components/entity/EntityLink';
import { Fragment } from 'react';
@ -18,7 +18,7 @@ export default function PhotoTags({
{tags.map(tag =>
<Fragment key={tag}>
{isTagFavs(tag)
? <FavsTag {...{
? <PhotoFavs {...{
contrast,
prefetch,
countOnHover: tagCounts[tag],

View File

@ -1,10 +1,10 @@
import { Photo, photoQuantityText } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import HiddenTag from './HiddenTag';
import PhotoPrivate from './PhotoPrivate';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default async function HiddenHeader({
export default async function PrivateHeader({
photos,
selectedPhoto,
indexNumber,
@ -19,7 +19,7 @@ export default async function HiddenHeader({
return (
<PhotoHeader
key="HiddenHeader"
entity={<HiddenTag contrast="high" />}
entity={<PhotoPrivate contrast="high" />}
entityDescription={photoQuantityText(count, appText, false, false)}
photos={photos}
selectedPhoto={selectedPhoto}

View File

@ -2,7 +2,7 @@ import { Photo, PhotoDateRange } from '@/photo';
import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos, isTagFavs } from '.';
import PhotoHeader from '@/photo/PhotoHeader';
import FavsTag from './FavsTag';
import PhotoFavs from './PhotoFavs';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
@ -26,7 +26,7 @@ export default async function TagHeader({
<PhotoHeader
tag={tag}
entity={isTagFavs(tag)
? <FavsTag
? <PhotoFavs
contrast="high"
showHover={false}
/>

View File

@ -20,7 +20,7 @@ import { AppTextState } from '@/i18n/state';
// Reserved tags
export const TAG_FAVS = 'favs';
export const TAG_HIDDEN = 'hidden';
export const TAG_PRIVATE = 'private';
type TagWithMeta = { tag: string } & CategoryQueryMeta;
@ -31,7 +31,7 @@ export const formatTag = (tag?: string) =>
export const getValidationMessageForTags = (tags?: string) => {
const reservedTags = (convertStringToArray(tags) ?? [])
.filter(tag => isTagFavs(tag) || isTagHidden(tag))
.filter(tag => isTagFavs(tag) || isTagPrivate(tag))
.map(tag => tag.toLocaleUpperCase());
return reservedTags.length
? `Reserved tags: ${reservedTags.join(', ').toLocaleLowerCase()}`
@ -135,20 +135,20 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
export const isPathFavs = (pathname?: string) =>
getPathComponents(pathname).tag === TAG_FAVS;
export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN;
export const isTagPrivate = (tag: string) => tag.toLowerCase() === TAG_PRIVATE;
export const addHiddenToTags = (
export const addPrivateToTags = (
tags: Tags,
countHidden = 0,
lastModifiedHidden = new Date(),
countPrivate = 0,
lastModifiedPrivate = new Date(),
) =>
countHidden > 0
countPrivate > 0
? tags
.filter(({ tag }) => tag === TAG_FAVS)
.concat({
tag: TAG_HIDDEN,
count: countHidden,
lastModified: lastModifiedHidden,
tag: TAG_PRIVATE,
count: countPrivate,
lastModified: lastModifiedPrivate,
})
.concat(tags
.filter(({ tag }) => tag !== TAG_FAVS)
@ -176,7 +176,7 @@ export const limitTagsByCount = (
tags.filter(({ tag, count }) => (
count >= minimumCount ||
isTagFavs(tag) ||
isTagHidden(tag) ||
isTagPrivate(tag) ||
(queryToInclude && tag
.toLocaleLowerCase()
.includes(queryToInclude.toLocaleLowerCase()))