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 #### Sorting
- `NEXT_PUBLIC_DEFAULT_SORT` - `NEXT_PUBLIC_DEFAULT_SORT`
- Sets default sort on grid/feed homepages - Sets default sort on grid/full homepages
- Accepted values: - Accepted values:
- `taken-at` (default) - `taken-at` (default)
- `taken-at-oldest-first` - `taken-at-oldest-first`
- `uploaded-at` - `uploaded-at`
- `uploaded-at-oldest-first` - `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_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 #### 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
@ -317,8 +317,8 @@ Thank you ❤️ translators: [@sconetto](https://github.com/sconetto) (`pt-br`,
#### Why are my grid thumbnails so small? #### 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`. > 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?” #### How secure are photos marked “private?”
> 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. > 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? #### 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." > Navigate to `/admin/configuration` and click "Clear Cache."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ import RecipeModal from '@/recipe/RecipeModal';
import ThemeColors from '@/app/ThemeColors'; import ThemeColors from '@/app/ThemeColors';
import AppTextProvider from '@/i18n/state/AppTextProvider'; import AppTextProvider from '@/i18n/state/AppTextProvider';
import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider'; import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider';
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
import '../tailwind.css'; import '../tailwind.css';
@ -71,7 +72,8 @@ export const metadata: Metadata = {
...SITE_FEEDS_ENABLED && { ...SITE_FEEDS_ENABLED && {
alternates: { alternates: {
types: { 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; export const maxDuration = 60;
const getPhotosNearIdCachedCached = cache((photoId: string) => 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( export const generateStaticParams = staticallyGeneratePhotosIfConfigured(
'page', 'page',

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import type { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { getDataForCategoriesCached } from '@/category/cache'; import { getDataForCategoriesCached } from '@/category/cache';
import { import {
ABSOLUTE_PATH_FULL,
ABSOLUTE_PATH_GRID,
absolutePathForCamera, absolutePathForCamera,
absolutePathForFilm, absolutePathForFilm,
absolutePathForFocalLength, absolutePathForFocalLength,
@ -72,9 +74,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: PRIORITY_HOME, priority: PRIORITY_HOME,
lastModified: lastModifiedSite, 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, priority: PRIORITY_HOME_VIEW,
lastModified: lastModifiedSite, lastModified: lastModifiedSite,
}, },

View File

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

View File

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

View File

@ -46,12 +46,12 @@ export const config = {
// - /_next/image* // - /_next/image*
// - /favicon.ico + /favicons/* // - /favicon.ico + /favicons/*
// - /grid // - /grid
// - /feed // - /full
// - / (root) // - / (root)
// - /home-image // - /home-image
// - /template-image // - /template-image
// - /template-image-tight // - /template-image-tight
// - /template-url // - /template-url
// eslint-disable-next-line max-len // 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>)} </Fragment>)}
</div> </div>
Change default sort on grid/feed homepages Change default sort on grid/full homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])} {renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
</ChecklistRow> </ChecklistRow>
<ChecklistRow <ChecklistRow
@ -654,7 +654,7 @@ export default function AdminAppConfigurationClient({
optional optional
> >
Set environment variable to {'"1"'} to 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'])} {renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
</ChecklistRow> </ChecklistRow>
</ChecklistGroup> </ChecklistGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,19 @@
import { IconBaseProps } from 'react-icons'; 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'; import { FiLock } from 'react-icons/fi';
export default function IconLock({ export default function IconLock({
solid,
narrow, narrow,
open,
...props ...props
}: IconBaseProps & { narrow?: boolean }) { }: IconBaseProps & { solid?: boolean, narrow?: boolean, open?: boolean }) {
return narrow if (solid) {
? <BiLockAlt {...props} /> return open ? <FaLockOpen {...props} /> : <FaLock {...props} />;
: <FiLock {...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 { import {
getNextImageUrlForRequest, INFINITE_SCROLL_FULL_INITIAL,
NextImageSize, INFINITE_SCROLL_GRID_INITIAL,
} from '@/platforms/next-image'; } 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; // PAGE FEED QUERY OPTIONS
export const FEED_PHOTO_WIDTH_MEDIUM = 640;
export const FEED_PHOTO_WIDTH_LARGE = 1200;
export interface FeedMedia { export const getFeedQueryOptions = ({
url: string isGrid,
width: number sortBy = USER_DEFAULT_SORT_OPTIONS.sortBy,
height: number sortWithPriority = USER_DEFAULT_SORT_OPTIONS.sortWithPriority,
} }: {
isGrid: boolean,
export const generateFeedMedia = ( sortBy?: SortBy,
photo: Photo, sortWithPriority?: boolean,
size: NextImageSize, }): PhotoQueryOptions => ({
): FeedMedia => ({ ...FEED_BASE_QUERY_OPTIONS,
url: getNextImageUrlForRequest({ imageUrl: photo.url, size }), sortBy,
width: size, sortWithPriority,
height: Math.round(size / photo.aspectRatio), limit: isGrid
? INFINITE_SCROLL_GRID_INITIAL
: INFINITE_SCROLL_FULL_INITIAL,
}); });
export const getCoreFeedFields = (photo: Photo) => ({ export const FEED_META_QUERY_OPTIONS: PhotoQueryOptions = {
id: photo.id, ...FEED_BASE_QUERY_OPTIONS,
title: titleForPhoto(photo), };
description: descriptionForPhoto(photo, true),
}); // 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, FeedMedia,
generateFeedMedia, generateFeedMedia,
getCoreFeedFields, getCoreFeedFields,
} from '.'; } from './programmatic';
import { formatDateFromPostgresString } from '@/utility/date'; import { formatDateFromPostgresString } from '@/utility/date';
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config'; 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, FeedMedia,
generateFeedMedia, generateFeedMedia,
getCoreFeedFields, getCoreFeedFields,
} from '.'; } from './programmatic';
import { ABSOLUTE_PATH_FOR_RSS_XML, absolutePathForPhoto } from '@/app/paths'; import { ABSOLUTE_PATH_RSS_XML, absolutePathForPhoto } from '@/app/paths';
import { formatDate } from '@/utility/date'; import { formatDate } from '@/utility/date';
import { formatStringForXml } from '@/utility/string'; import { formatStringForXml } from '@/utility/string';
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config'; import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
@ -67,7 +67,7 @@ export const formatFeedRssXml = (photos: Photo[]) =>
<channel> <channel>
<title>${META_TITLE}</title> <title>${META_TITLE}</title>
<atom:link <atom:link
href="${ABSOLUTE_PATH_FOR_RSS_XML}" href="${ABSOLUTE_PATH_RSS_XML}"
rel="self" rel="self"
type="application/rss+xml" type="application/rss+xml"
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,6 +71,13 @@ export const MIGRATIONS: Migration[] = [{
END IF; END IF;
END $$; 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) => export const migrationForError = (e: any) =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
export const KEY_COMMANDS = { export const KEY_COMMANDS = {
feed: 'F', full: 'F',
grid: 'G', grid: 'G',
admin: 'A', admin: 'A',
prev: ['J', 'ARROWLEFT'], prev: ['J', 'ARROWLEFT'],
@ -7,7 +7,7 @@ export const KEY_COMMANDS = {
edit: 'E', edit: 'E',
favorite: 'P', favorite: 'P',
unfavorite: 'X', unfavorite: 'X',
toggleHide: 'H', togglePrivate: 'M',
download: 'D', download: 'D',
sync: 'S', sync: 'S',
search: ['⌘', 'K'], 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'; } from '@/components/entity/EntityLink';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
export default function FavsTag(props: EntityLinkExternalProps) { export default function PhotoFavs(props: EntityLinkExternalProps) {
return ( return (
<EntityLink <EntityLink
{...props} {...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 PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.'; import { isTagFavs } from '.';
import FavsTag from './FavsTag'; import PhotoFavs from './PhotoFavs';
import { EntityLinkExternalProps } from '@/components/entity/EntityLink'; import { EntityLinkExternalProps } from '@/components/entity/EntityLink';
import { Fragment } from 'react'; import { Fragment } from 'react';
@ -18,7 +18,7 @@ export default function PhotoTags({
{tags.map(tag => {tags.map(tag =>
<Fragment key={tag}> <Fragment key={tag}>
{isTagFavs(tag) {isTagFavs(tag)
? <FavsTag {...{ ? <PhotoFavs {...{
contrast, contrast,
prefetch, prefetch,
countOnHover: tagCounts[tag], countOnHover: tagCounts[tag],

View File

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

View File

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

View File

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