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:
parent
bf78f786a7
commit
70f6f48044
@ -146,14 +146,14 @@ Application behavior can be changed by configuring the following environment var
|
||||
|
||||
#### Sorting
|
||||
- `NEXT_PUBLIC_DEFAULT_SORT`
|
||||
- Sets default sort on grid/feed homepages
|
||||
- Sets default sort on grid/full homepages
|
||||
- Accepted values:
|
||||
- `taken-at` (default)
|
||||
- `taken-at-oldest-first`
|
||||
- `uploaded-at`
|
||||
- `uploaded-at-oldest-first`
|
||||
- `NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1` takes priority field into account when sorting photos (⚠️ enabling may have performance consequences)
|
||||
- `NEXT_PUBLIC_SHOW_SORT_CONTROL = 1` shows sort control in desktop nav on grid/feed homepages
|
||||
- `NEXT_PUBLIC_SHOW_SORT_CONTROL = 1` shows sort control in desktop nav on grid/full homepages
|
||||
|
||||
#### Display
|
||||
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
|
||||
@ -317,8 +317,8 @@ Thank you ❤️ translators: [@sconetto](https://github.com/sconetto) (`pt-br`,
|
||||
#### Why are my grid thumbnails so small?
|
||||
> Thumbnail grid density (seen on `/grid`, tag overviews, and other photo sets) is dependent on aspect ratio configuration (ratios of 1 or less have more photos per row). This can be overridden by setting `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1`.
|
||||
|
||||
#### How secure are photos marked “hidden?”
|
||||
> While all hidden paths (`/tag/hidden/*`) require authentication, raw links to individual photo assets remain publicly accessible. Randomly generated urls from storage providers are only secure via obscurity. Use with caution.
|
||||
#### How secure are photos marked “private?”
|
||||
> While all private paths (`/tag/private/*`) require authentication, raw links to individual photo assets remain publicly accessible. Randomly generated urls from storage providers are only secure via obscurity. Use with caution.
|
||||
|
||||
#### My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?
|
||||
> Navigate to `/admin/configuration` and click "Clear Cache."
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
isPathTag,
|
||||
isPathTagPhoto,
|
||||
} from '@/app/paths';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
|
||||
const PHOTO_ID = 'UsKSGcbt';
|
||||
const TAG = 'tag-name';
|
||||
@ -25,7 +25,7 @@ const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`;
|
||||
|
||||
const PATH_ROOT = '/';
|
||||
const PATH_GRID = '/grid';
|
||||
const PATH_FEED = '/feed';
|
||||
const PATH_FULL = '/full';
|
||||
const PATH_ADMIN = '/admin/photos';
|
||||
const PATH_OG = '/og';
|
||||
const PATH_OG_ALL = `${PATH_OG}/all`;
|
||||
@ -36,8 +36,8 @@ const PATH_PHOTO = `/p/${PHOTO_ID}`;
|
||||
const PATH_TAG = `/tag/${TAG}`;
|
||||
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_TAG_HIDDEN = `/tag/${TAG_HIDDEN}`;
|
||||
const PATH_TAG_HIDDEN_PHOTO = `${PATH_TAG_HIDDEN}/${PHOTO_ID}`;
|
||||
const PATH_TAG_PRIVATE = `/tag/${TAG_PRIVATE}`;
|
||||
const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`;
|
||||
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`;
|
||||
@ -62,8 +62,8 @@ describe('Paths', () => {
|
||||
expect(isPathProtected(PATH_OG)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG_ALL)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_PRIVATE)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_PRIVATE_PHOTO)).toBe(true);
|
||||
});
|
||||
it('can be classified', () => {
|
||||
// Positive
|
||||
@ -123,7 +123,7 @@ describe('Paths', () => {
|
||||
// Root
|
||||
expect(getEscapePath(PATH_ROOT)).toEqual(undefined);
|
||||
expect(getEscapePath(PATH_GRID)).toEqual(undefined);
|
||||
expect(getEscapePath(PATH_FEED)).toEqual(undefined);
|
||||
expect(getEscapePath(PATH_FULL)).toEqual(undefined);
|
||||
expect(getEscapePath(PATH_ADMIN)).toEqual(undefined);
|
||||
// Photo
|
||||
expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_ROOT);
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import { SITE_FEEDS_ENABLED } from '@/app/config';
|
||||
import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed';
|
||||
import { formatFeedJson } from '@/feed/json';
|
||||
import { PROGRAMMATIC_QUERY_OPTIONS } from '@/feed';
|
||||
|
||||
// Cache for 24 hours
|
||||
export const revalidate = 86_400;
|
||||
|
||||
export async function GET() {
|
||||
if (SITE_FEEDS_ENABLED) {
|
||||
const photos = await getPhotosCached({
|
||||
limit: FEED_PHOTO_REQUEST_LIMIT,
|
||||
sortBy: 'createdAt',
|
||||
}).catch(() => []);
|
||||
const photos = await getPhotosCached(PROGRAMMATIC_QUERY_OPTIONS)
|
||||
.catch(() => []);
|
||||
return Response.json(formatFeedJson(photos));
|
||||
} else {
|
||||
return new Response('Feeds disabled', { status: 404 });
|
||||
|
||||
@ -1,52 +1,51 @@
|
||||
import {
|
||||
INFINITE_SCROLL_FEED_INITIAL,
|
||||
generateOgImageMetaForPhotos,
|
||||
} from '@/photo';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||
import PhotoFullPage from '@/photo/PhotoFullPage';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { SortProps } from '@/photo/db/sort';
|
||||
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) =>
|
||||
getPhotos(getFeedQueryOptions({
|
||||
isGrid: false,
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||
}));
|
||||
})));
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: SortProps): Promise<Metadata> {
|
||||
const options = await getSortOptionsFromParams(params);
|
||||
const photos = await getPhotosCached(options)
|
||||
const sortOptions = await getSortOptionsFromParams(params);
|
||||
const photos = await getPhotosCached(sortOptions)
|
||||
.catch(() => []);
|
||||
return generateOgImageMetaForPhotos(photos);
|
||||
}
|
||||
|
||||
export default async function FeedPageSort({ params }: SortProps) {
|
||||
const options = await getSortOptionsFromParams(params);
|
||||
export default async function FullPageSort({ params }: SortProps) {
|
||||
const sortOptions = await getSortOptionsFromParams(params);
|
||||
const [
|
||||
photos,
|
||||
photosCount,
|
||||
] = await Promise.all([
|
||||
getPhotosCached(options)
|
||||
getPhotosCached(sortOptions)
|
||||
.catch(() => []),
|
||||
getPhotosMetaCached(options)
|
||||
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
|
||||
return (
|
||||
photos.length > 0
|
||||
? <PhotoFeedPage {...{
|
||||
? <PhotoFullPage {...{
|
||||
photos,
|
||||
photosCount,
|
||||
...options,
|
||||
...sortOptions,
|
||||
}} />
|
||||
: <PhotosEmptyState />
|
||||
);
|
||||
@ -1,45 +1,41 @@
|
||||
import {
|
||||
INFINITE_SCROLL_FEED_INITIAL,
|
||||
generateOgImageMetaForPhotos,
|
||||
} from '@/photo';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||
import PhotoFullPage from '@/photo/PhotoFullPage';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||
}));
|
||||
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
|
||||
isGrid: false,
|
||||
})));
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
const photos = await getPhotosCached()
|
||||
.catch(() => []);
|
||||
return generateOgImageMetaForPhotos(photos);
|
||||
}
|
||||
|
||||
export default async function FeedPage() {
|
||||
export default async function FullPage() {
|
||||
const [
|
||||
photos,
|
||||
photosCount,
|
||||
] = await Promise.all([
|
||||
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
getPhotosCached()
|
||||
.catch(() => []),
|
||||
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
|
||||
return (
|
||||
photos.length > 0
|
||||
? <PhotoFeedPage {...{
|
||||
? <PhotoFullPage {...{
|
||||
photos,
|
||||
photosCount,
|
||||
...USER_DEFAULT_SORT_OPTIONS,
|
||||
@ -1,7 +1,4 @@
|
||||
import {
|
||||
INFINITE_SCROLL_GRID_INITIAL,
|
||||
generateOgImageMetaForPhotos,
|
||||
} from '@/photo';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
@ -11,34 +8,36 @@ import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { SortProps } from '@/photo/db/sort';
|
||||
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) =>
|
||||
getPhotos(getFeedQueryOptions({
|
||||
isGrid: true,
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||
}));
|
||||
})));
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: SortProps): Promise<Metadata> {
|
||||
const options = await getSortOptionsFromParams(params);
|
||||
const photos = await getPhotosCached(options)
|
||||
const sortOptions = await getSortOptionsFromParams(params);
|
||||
const photos = await getPhotosCached(sortOptions)
|
||||
.catch(() => []);
|
||||
return generateOgImageMetaForPhotos(photos);
|
||||
}
|
||||
|
||||
export default async function GridPage({ params }: SortProps) {
|
||||
const options = await getSortOptionsFromParams(params);
|
||||
const sortOptions = await getSortOptionsFromParams(params);
|
||||
const [
|
||||
photos,
|
||||
photosCount,
|
||||
categories,
|
||||
] = await Promise.all([
|
||||
getPhotosCached(options)
|
||||
getPhotosCached(sortOptions)
|
||||
.catch(() => []),
|
||||
getPhotosMetaCached(options)
|
||||
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
getDataForCategoriesCached(),
|
||||
@ -50,7 +49,7 @@ export default async function GridPage({ params }: SortProps) {
|
||||
{...{
|
||||
photos,
|
||||
photosCount,
|
||||
...options,
|
||||
...sortOptions,
|
||||
...categories,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import {
|
||||
INFINITE_SCROLL_GRID_INITIAL,
|
||||
generateOgImageMetaForPhotos,
|
||||
} from '@/photo';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
@ -10,18 +7,17 @@ import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||
}));
|
||||
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
|
||||
isGrid: true,
|
||||
})));
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
const photos = await getPhotosCached()
|
||||
.catch(() => []);
|
||||
return generateOgImageMetaForPhotos(photos);
|
||||
}
|
||||
@ -32,9 +28,9 @@ export default async function GridPage() {
|
||||
photosCount,
|
||||
categories,
|
||||
] = await Promise.all([
|
||||
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
getPhotosCached()
|
||||
.catch(() => []),
|
||||
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
getDataForCategoriesCached(),
|
||||
|
||||
@ -30,6 +30,7 @@ import RecipeModal from '@/recipe/RecipeModal';
|
||||
import ThemeColors from '@/app/ThemeColors';
|
||||
import AppTextProvider from '@/i18n/state/AppTextProvider';
|
||||
import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider';
|
||||
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths';
|
||||
|
||||
import '../tailwind.css';
|
||||
|
||||
@ -71,7 +72,8 @@ export const metadata: Metadata = {
|
||||
...SITE_FEEDS_ENABLED && {
|
||||
alternates: {
|
||||
types: {
|
||||
'application/rss+xml': '/rss.xml',
|
||||
'application/rss+xml': PATH_RSS_XML,
|
||||
'application/json': PATH_FEED_JSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -18,7 +18,13 @@ import { staticallyGeneratePhotosIfConfigured } from '@/app/static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosNearIdCachedCached = cache((photoId: string) =>
|
||||
getPhotosNearIdCached(photoId, { limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 }));
|
||||
getPhotosNearIdCached(
|
||||
photoId, {
|
||||
limit: RELATED_GRID_PHOTOS_TO_SHOW + 2,
|
||||
},
|
||||
// Don't show photo in context when excluded from feeds
|
||||
true,
|
||||
));
|
||||
|
||||
export const generateStaticParams = staticallyGeneratePhotosIfConfigured(
|
||||
'page',
|
||||
|
||||
27
app/page.tsx
27
app/page.tsx
@ -1,32 +1,25 @@
|
||||
import {
|
||||
INFINITE_SCROLL_FEED_INITIAL,
|
||||
INFINITE_SCROLL_GRID_INITIAL,
|
||||
generateOgImageMetaForPhotos,
|
||||
} from '@/photo';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { Metadata } from 'next/types';
|
||||
import { cache } from 'react';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { GRID_HOMEPAGE_ENABLED, USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { NULL_CATEGORY_DATA } from '@/category/data';
|
||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||
import PhotoFullPage from '@/photo/PhotoFullPage';
|
||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: GRID_HOMEPAGE_ENABLED
|
||||
? INFINITE_SCROLL_GRID_INITIAL
|
||||
: INFINITE_SCROLL_FEED_INITIAL,
|
||||
}));
|
||||
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
|
||||
isGrid: GRID_HOMEPAGE_ENABLED,
|
||||
})));
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const photos = await getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
const photos = await getPhotosCached()
|
||||
.catch(() => []);
|
||||
return generateOgImageMetaForPhotos(photos);
|
||||
}
|
||||
@ -37,9 +30,9 @@ export default async function HomePage() {
|
||||
photosCount,
|
||||
categories,
|
||||
] = await Promise.all([
|
||||
getPhotosCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
getPhotosCached()
|
||||
.catch(() => []),
|
||||
getPhotosMetaCached(USER_DEFAULT_SORT_OPTIONS)
|
||||
getPhotosMetaCached(FEED_META_QUERY_OPTIONS)
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
GRID_HOMEPAGE_ENABLED
|
||||
@ -58,7 +51,7 @@ export default async function HomePage() {
|
||||
...categories,
|
||||
}}
|
||||
/>
|
||||
: <PhotoFeedPage {...{
|
||||
: <PhotoFullPage {...{
|
||||
photos,
|
||||
photosCount,
|
||||
...USER_DEFAULT_SORT_OPTIONS,
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import { SITE_FEEDS_ENABLED } from '@/app/config';
|
||||
import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed';
|
||||
import { formatFeedRssXml } from '@/feed/rss';
|
||||
import { PROGRAMMATIC_QUERY_OPTIONS } from '@/feed';
|
||||
|
||||
// Cache for 24 hours
|
||||
export const revalidate = 86_400;
|
||||
|
||||
export async function GET() {
|
||||
if (SITE_FEEDS_ENABLED) {
|
||||
const photos = await getPhotosCached({
|
||||
limit: FEED_PHOTO_REQUEST_LIMIT,
|
||||
sortBy: 'createdAt',
|
||||
}).catch(() => []);
|
||||
const photos = await getPhotosCached(PROGRAMMATIC_QUERY_OPTIONS)
|
||||
.catch(() => []);
|
||||
return new Response(
|
||||
formatFeedRssXml(photos),
|
||||
{ headers: { 'Content-Type': 'text/xml' } },
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import {
|
||||
ABSOLUTE_PATH_FULL,
|
||||
ABSOLUTE_PATH_GRID,
|
||||
absolutePathForCamera,
|
||||
absolutePathForFilm,
|
||||
absolutePathForFocalLength,
|
||||
@ -72,9 +74,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: PRIORITY_HOME,
|
||||
lastModified: lastModifiedSite,
|
||||
},
|
||||
// Grid or Feed
|
||||
// Grid or full
|
||||
{
|
||||
url: GRID_HOMEPAGE_ENABLED ? `${BASE_URL}/feed` : `${BASE_URL}/grid`,
|
||||
url: GRID_HOMEPAGE_ENABLED ? ABSOLUTE_PATH_FULL : ABSOLUTE_PATH_GRID,
|
||||
priority: PRIORITY_HOME_VIEW,
|
||||
lastModified: lastModifiedSite,
|
||||
},
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
getPhotosNearIdCached,
|
||||
} from '@/photo/cache';
|
||||
import { PATH_ROOT, absolutePathForPhoto } from '@/app/paths';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { cache } from 'react';
|
||||
@ -36,7 +36,7 @@ export async function generateMetadata({
|
||||
const title = titleForPhoto(photo);
|
||||
const description = descriptionForPhoto(photo);
|
||||
const descriptionHtml = descriptionForPhoto(photo, true);
|
||||
const url = absolutePathForPhoto({ photo, tag: TAG_HIDDEN });
|
||||
const url = absolutePathForPhoto({ photo, tag: TAG_PRIVATE });
|
||||
|
||||
return {
|
||||
title,
|
||||
@ -54,7 +54,7 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoTagHiddenPage({
|
||||
export default async function PhotoTagPrivatePage({
|
||||
params,
|
||||
}: PhotoTagProps) {
|
||||
const { photoId } = await params;
|
||||
@ -74,7 +74,7 @@ export default async function PhotoTagHiddenPage({
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
tag: TAG_HIDDEN,
|
||||
tag: TAG_PRIVATE,
|
||||
shouldShare: false,
|
||||
includeFavoriteInAdminMenu: false,
|
||||
}} />
|
||||
@ -4,8 +4,8 @@ import AppGrid from '@/components/AppGrid';
|
||||
import PhotoGrid from '@/photo/PhotoGrid';
|
||||
import { getPhotosMetaCached, getPhotosNoStore } from '@/photo/cache';
|
||||
import { absolutePathForTag } from '@/app/paths';
|
||||
import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag';
|
||||
import HiddenHeader from '@/tag/HiddenHeader';
|
||||
import { TAG_PRIVATE, descriptionForTaggedPhotos, titleForTag } from '@/tag';
|
||||
import PrivateHeader from '@/tag/PrivateHeader';
|
||||
import { Metadata } from 'next';
|
||||
import { cache } from 'react';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
@ -20,7 +20,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
const appText = await getAppText();
|
||||
|
||||
const title = titleForTag(TAG_HIDDEN, undefined, appText, count);
|
||||
const title = titleForTag(TAG_PRIVATE, undefined, appText, count);
|
||||
|
||||
const description = descriptionForTaggedPhotos(
|
||||
undefined,
|
||||
@ -29,7 +29,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
count,
|
||||
dateRange,
|
||||
);
|
||||
const url = absolutePathForTag(TAG_HIDDEN);
|
||||
const url = absolutePathForTag(TAG_PRIVATE);
|
||||
|
||||
return {
|
||||
title,
|
||||
@ -46,7 +46,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HiddenTagPage() {
|
||||
export default async function PrivateTagPage() {
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
@ -60,15 +60,15 @@ export default async function HiddenTagPage() {
|
||||
contentMain={<div className="space-y-4 mt-4">
|
||||
<AnimateItems
|
||||
type="bottom"
|
||||
items={[<HiddenHeader
|
||||
key="HiddenHeader"
|
||||
items={[<PrivateHeader
|
||||
key="PrivateHeader"
|
||||
{...{ photos, count, dateRange }}
|
||||
/>]}
|
||||
animateOnFirstLoadOnly
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<Note animate>
|
||||
Only visible to authenticated admins
|
||||
Visible only to admins (uploads only secure via obscurity)
|
||||
</Note>
|
||||
<PhotoGrid {...{ photos }} />
|
||||
</div>
|
||||
@ -46,12 +46,12 @@ export const config = {
|
||||
// - /_next/image*
|
||||
// - /favicon.ico + /favicons/*
|
||||
// - /grid
|
||||
// - /feed
|
||||
// - /full
|
||||
// - / (root)
|
||||
// - /home-image
|
||||
// - /template-image
|
||||
// - /template-image-tight
|
||||
// - /template-url
|
||||
// eslint-disable-next-line max-len
|
||||
matcher: ['/((?!api$|api/auth|_next/static|_next/image|favicon.ico$|favicons/|grid$|feed$|home-image$|template-image$|template-image-tight$|template-url$|$).*)'],
|
||||
matcher: ['/((?!api$|api/auth|_next/static|_next/image|favicon.ico$|favicons/|grid$|full$|home-image$|template-image$|template-image-tight$|template-url$|$).*)'],
|
||||
};
|
||||
|
||||
@ -635,7 +635,7 @@ export default function AdminAppConfigurationClient({
|
||||
)}
|
||||
</Fragment>)}
|
||||
</div>
|
||||
Change default sort on grid/feed homepages
|
||||
Change default sort on grid/full homepages
|
||||
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -654,7 +654,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to
|
||||
show sort control in desktop nav on grid/feed homepages:
|
||||
show sort control in desktop nav on grid/full homepages:
|
||||
{renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
|
||||
</ChecklistRow>
|
||||
</ChecklistGroup>
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import sleep from '@/utility/sleep';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Dispatch, SetStateAction, useRef, useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
||||
import { BiCheckCircle } from 'react-icons/bi';
|
||||
import ProgressButton from '@/components/primitives/ProgressButton';
|
||||
import { UrlAddStatus } from './AdminUploadsClient';
|
||||
@ -22,8 +22,9 @@ import DeleteUploadButton from './DeleteUploadButton';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { pluralize } from '@/utility/string';
|
||||
import FieldsetFavs from '@/photo/form/FieldsetFavs';
|
||||
import FieldsetHidden from '@/photo/form/FieldsetHidden';
|
||||
import FieldsetPrivate from '@/photo/form/FieldsetPrivate';
|
||||
import IconAddUpload from '@/components/icons/IconAddUpload';
|
||||
import FieldsetExclude from '@/photo/form/FieldsetExclude';
|
||||
|
||||
const UPLOAD_BATCH_SIZE = 2;
|
||||
|
||||
@ -53,6 +54,7 @@ export default function AdminBatchUploadActions({
|
||||
const [showBulkSettings, setShowBulkSettings] = useState(false);
|
||||
const [tags, setTags] = useState('');
|
||||
const [favorite, setFavorite] = useState('false');
|
||||
const [excludeFromFeeds, setExcludeFromFeeds] = useState('false');
|
||||
const [hidden, setHidden] = useState('false');
|
||||
const [tagErrorMessage, setTagErrorMessage] = useState('');
|
||||
|
||||
@ -76,6 +78,7 @@ export default function AdminBatchUploadActions({
|
||||
...showBulkSettings && {
|
||||
tags,
|
||||
favorite,
|
||||
excludeFromFeeds,
|
||||
hidden,
|
||||
},
|
||||
takenAtLocal: generateLocalPostgresString(),
|
||||
@ -123,12 +126,19 @@ export default function AdminBatchUploadActions({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hidden === 'true') {
|
||||
setFavorite('false');
|
||||
setExcludeFromFeeds('false');
|
||||
}
|
||||
}, [hidden]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionErrorMessage &&
|
||||
<ErrorNote>{actionErrorMessage}</ErrorNote>}
|
||||
<Container padding="tight">
|
||||
<div className="w-full space-y-4 py-1">
|
||||
<Container padding="tight" className="p-2! sm:p-3!">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex">
|
||||
<div className="grow text-main">
|
||||
{showBulkSettings
|
||||
@ -154,20 +164,25 @@ export default function AdminBatchUploadActions({
|
||||
readOnly={isAdding}
|
||||
className="relative z-10"
|
||||
/>
|
||||
<div className="flex gap-8">
|
||||
<div className="flex max-sm:flex-col gap-x-8 gap-y-4">
|
||||
<FieldsetFavs
|
||||
value={favorite}
|
||||
onChange={setFavorite}
|
||||
readOnly={isAdding}
|
||||
readOnly={isAdding || hidden === 'true'}
|
||||
/>
|
||||
<FieldsetHidden
|
||||
<FieldsetExclude
|
||||
value={excludeFromFeeds}
|
||||
onChange={setExcludeFromFeeds}
|
||||
readOnly={isAdding || hidden === 'true'}
|
||||
/>
|
||||
<FieldsetPrivate
|
||||
value={hidden}
|
||||
onChange={setHidden}
|
||||
readOnly={isAdding}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row-reverse gap-2">
|
||||
<ProgressButton
|
||||
primary
|
||||
className="w-full justify-center"
|
||||
|
||||
@ -11,14 +11,14 @@ import {
|
||||
deletePhotoAction,
|
||||
syncPhotoAction,
|
||||
toggleFavoritePhotoAction,
|
||||
toggleHidePhotoAction,
|
||||
togglePrivatePhotoAction,
|
||||
} from '@/photo/actions';
|
||||
import {
|
||||
Photo,
|
||||
deleteConfirmationTextForPhoto,
|
||||
downloadFileNameForPhoto,
|
||||
} from '@/photo';
|
||||
import { isPathFavs, isPhotoFav, TAG_HIDDEN } from '@/tag';
|
||||
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
import MoreMenu from '@/components/more/MoreMenu';
|
||||
@ -33,7 +33,7 @@ import IconEdit from '@/components/icons/IconEdit';
|
||||
import { photoNeedsToBeSynced } from '@/photo/sync';
|
||||
import { KEY_COMMANDS } from '@/photo/key-commands';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
import IconLock from '@/components/icons/IconLock';
|
||||
|
||||
export default function AdminPhotoMenu({
|
||||
photo,
|
||||
@ -57,9 +57,9 @@ export default function AdminPhotoMenu({
|
||||
const isFav = isPhotoFav(photo);
|
||||
const shouldRedirectFav = isPathFavs(path) && isFav;
|
||||
const shouldRedirectDelete = isOnPhotoDetail;
|
||||
const redirectPathOnHideToggle = isOnPhotoDetail
|
||||
const redirectPathOnPrivateToggle = isOnPhotoDetail
|
||||
? photo.hidden
|
||||
? pathForTag(TAG_HIDDEN)
|
||||
? pathForTag(TAG_PRIVATE)
|
||||
: PATH_ROOT
|
||||
: undefined;
|
||||
|
||||
@ -68,8 +68,8 @@ export default function AdminPhotoMenu({
|
||||
const items: ComponentProps<typeof MoreMenuItem>[] = [{
|
||||
label: appText.admin.edit,
|
||||
icon: <IconEdit
|
||||
size={15}
|
||||
className="translate-x-[0.5px]"
|
||||
size={14}
|
||||
className="translate-x-[0.5px] translate-y-[0.5px]"
|
||||
/>,
|
||||
href: pathForAdminPhotoEdit(photo.id),
|
||||
...showKeyCommands && { keyCommand: KEY_COMMANDS.edit },
|
||||
@ -94,19 +94,20 @@ export default function AdminPhotoMenu({
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
label: photo.hidden ? appText.admin.unhide : appText.admin.hide,
|
||||
icon: <IconHidden
|
||||
size={17}
|
||||
className="translate-x-[-1px] translate-y-[1px]"
|
||||
visible={photo.hidden}
|
||||
label: photo.hidden ? appText.admin.public : appText.admin.private,
|
||||
icon: <IconLock
|
||||
size={16}
|
||||
className="translate-x-[-1.5px] translate-y-[0.5px]"
|
||||
open={!photo.hidden}
|
||||
narrow
|
||||
/>,
|
||||
action: () => toggleHidePhotoAction(
|
||||
action: () => togglePrivatePhotoAction(
|
||||
photo.id,
|
||||
redirectPathOnHideToggle,
|
||||
redirectPathOnPrivateToggle,
|
||||
)
|
||||
.then(() => revalidatePhoto?.(photo.id)),
|
||||
...showKeyCommands && {
|
||||
keyCommand: KEY_COMMANDS.toggleHide,
|
||||
keyCommand: KEY_COMMANDS.togglePrivate,
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
@ -146,7 +147,7 @@ export default function AdminPhotoMenu({
|
||||
includeFavorite,
|
||||
isFav,
|
||||
shouldRedirectFav,
|
||||
redirectPathOnHideToggle,
|
||||
redirectPathOnPrivateToggle,
|
||||
revalidatePhoto,
|
||||
]);
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import { Timezone } from '@/utility/timezone';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
|
||||
import IconLock from '@/components/icons/IconLock';
|
||||
|
||||
export default function AdminPhotosTable({
|
||||
photos,
|
||||
@ -76,13 +77,20 @@ export default function AdminPhotosTable({
|
||||
<span className="truncate">
|
||||
{titleForPhoto(photo, false)}
|
||||
</span>
|
||||
{photo.hidden &&
|
||||
{photo.excludeFromFeeds && !photo.hidden &&
|
||||
<span>
|
||||
<IconHidden
|
||||
className="inline translate-y-[-0.5px]"
|
||||
className="inline translate-y-[-1px]"
|
||||
size={16}
|
||||
/>
|
||||
</span>}
|
||||
{photo.hidden &&
|
||||
<span>
|
||||
<IconLock
|
||||
size={13}
|
||||
className="inline translate-y-[-1.5px]"
|
||||
/>
|
||||
</span>}
|
||||
</span>
|
||||
{photo.priorityOrder !== null &&
|
||||
<span className={clsx(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { photoLabelForCount } from '@/photo';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import FavsTag from '@/tag/FavsTag';
|
||||
import PhotoFavs from '@/tag/PhotoFavs';
|
||||
import { isTagFavs } from '@/tag';
|
||||
import Badge from '@/components/Badge';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
@ -25,7 +25,7 @@ export default async function AdminTagBadge({
|
||||
isTagFavs(tag) && 'translate-y-[0.5px]',
|
||||
)}>
|
||||
{isTagFavs(tag)
|
||||
? <FavsTag />
|
||||
? <PhotoFavs />
|
||||
: <PhotoTag {...{ tag }} />}
|
||||
<div className="text-dim uppercase">
|
||||
<span>{count}</span>
|
||||
|
||||
@ -75,7 +75,7 @@ export default function AdminUploadsTableRow({
|
||||
className={clsx(
|
||||
'flex items-center grow',
|
||||
'transition-opacity',
|
||||
'rounded-md overflow-hidden',
|
||||
'rounded-lg overflow-hidden',
|
||||
'border-medium bg-extra-dim',
|
||||
isAdding && !isComplete && status !== 'adding' && 'opacity-30',
|
||||
)}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import Switcher from '@/components/Switcher';
|
||||
import SwitcherItem from '@/components/SwitcherItem';
|
||||
import IconFeed from '@/components/icons/IconFeed';
|
||||
import IconFull from '@/components/icons/IconFull';
|
||||
import IconGrid from '@/components/icons/IconGrid';
|
||||
import {
|
||||
doesPathOfferSort,
|
||||
PATH_FEED_INFERRED,
|
||||
PATH_FULL_INFERRED,
|
||||
PATH_GRID_INFERRED,
|
||||
} from '@/app/paths';
|
||||
import IconSearch from '../components/icons/IconSearch';
|
||||
@ -26,7 +26,7 @@ import IconSort from '@/components/icons/IconSort';
|
||||
import { getSortConfigFromPath } from '@/photo/db/sort-path';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
|
||||
export type SwitcherSelection = 'full' | 'grid' | 'admin';
|
||||
|
||||
const GAP_CLASS = 'mr-1.5 sm:mr-2';
|
||||
|
||||
@ -55,7 +55,7 @@ export default function AppViewSwitcher({
|
||||
sortBy,
|
||||
isAscending,
|
||||
pathGrid,
|
||||
pathFeed,
|
||||
pathFull,
|
||||
pathSort,
|
||||
} = getSortConfigFromPath(pathname);
|
||||
|
||||
@ -68,14 +68,14 @@ export default function AppViewSwitcher({
|
||||
hasLoadedRef.current = true;
|
||||
}, [invalidateSwr, sortBy]);
|
||||
|
||||
const refHrefFeed = useRef<HTMLAnchorElement>(null);
|
||||
const refHrefFull = useRef<HTMLAnchorElement>(null);
|
||||
const refHrefGrid = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!e.metaKey) {
|
||||
switch (e.key.toLocaleUpperCase()) {
|
||||
case KEY_COMMANDS.feed:
|
||||
if (pathname !== PATH_FEED_INFERRED) { refHrefFeed.current?.click(); }
|
||||
case KEY_COMMANDS.full:
|
||||
if (pathname !== PATH_FULL_INFERRED) { refHrefFull.current?.click(); }
|
||||
break;
|
||||
case KEY_COMMANDS.grid:
|
||||
if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); }
|
||||
@ -90,15 +90,15 @@ export default function AppViewSwitcher({
|
||||
|
||||
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
|
||||
|
||||
const renderItemFeed =
|
||||
const renderItemFull =
|
||||
<SwitcherItem
|
||||
icon={<IconFeed includeTitle={false} />}
|
||||
href={pathFeed}
|
||||
hrefRef={refHrefFeed}
|
||||
active={currentSelection === 'feed'}
|
||||
icon={<IconFull includeTitle={false} />}
|
||||
href={pathFull}
|
||||
hrefRef={refHrefFull}
|
||||
active={currentSelection === 'full'}
|
||||
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||
content: appText.nav.feed,
|
||||
keyCommand: KEY_COMMANDS.feed,
|
||||
content: appText.nav.full,
|
||||
keyCommand: KEY_COMMANDS.full,
|
||||
}}}
|
||||
noPadding
|
||||
/>;
|
||||
@ -119,8 +119,8 @@ export default function AppViewSwitcher({
|
||||
return (
|
||||
<div className={clsx('flex', className)}>
|
||||
<Switcher className={GAP_CLASS}>
|
||||
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFeed}
|
||||
{GRID_HOMEPAGE_ENABLED ? renderItemFeed : renderItemGrid}
|
||||
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
|
||||
{GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid}
|
||||
{/* Show spinner if admin is suspected to be logged in */}
|
||||
{(isUserSignedInEager && !isUserSignedIn) &&
|
||||
<SwitcherItem
|
||||
|
||||
@ -8,7 +8,7 @@ import AppViewSwitcher, { SwitcherSelection } from '@/app/AppViewSwitcher';
|
||||
import {
|
||||
PATH_ROOT,
|
||||
isPathAdmin,
|
||||
isPathFeed,
|
||||
isPathFull,
|
||||
isPathGrid,
|
||||
isPathProtected,
|
||||
isPathSignIn,
|
||||
@ -58,11 +58,11 @@ export default function Nav({
|
||||
|
||||
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
|
||||
if (pathname === PATH_ROOT) {
|
||||
return GRID_HOMEPAGE_ENABLED ? 'grid' : 'feed';
|
||||
return GRID_HOMEPAGE_ENABLED ? 'grid' : 'full';
|
||||
} else if (isPathGrid(pathname)) {
|
||||
return 'grid';
|
||||
} else if (isPathFeed(pathname)) {
|
||||
return 'feed';
|
||||
} else if (isPathFull(pathname)) {
|
||||
return 'full';
|
||||
} else if (isPathProtected(pathname)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
@ -3,13 +3,13 @@ import { PhotoSetCategory } from '@/category';
|
||||
import { getBaseUrl, GRID_HOMEPAGE_ENABLED } from './config';
|
||||
import { Camera } from '@/camera';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
import { Lens } from '@/lens';
|
||||
|
||||
// Core
|
||||
export const PATH_ROOT = '/';
|
||||
export const PATH_GRID = '/grid';
|
||||
export const PATH_FEED = '/feed';
|
||||
export const PATH_FULL = '/full';
|
||||
export const PATH_ADMIN = '/admin';
|
||||
export const PATH_API = '/api';
|
||||
export const PATH_SIGN_IN = '/sign-in';
|
||||
@ -19,8 +19,8 @@ export const PATH_OG = '/og';
|
||||
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
|
||||
? PATH_ROOT
|
||||
: PATH_GRID;
|
||||
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
|
||||
? PATH_FEED
|
||||
export const PATH_FULL_INFERRED = GRID_HOMEPAGE_ENABLED
|
||||
? PATH_FULL
|
||||
: PATH_ROOT;
|
||||
|
||||
// Sort
|
||||
@ -31,7 +31,7 @@ export const PARAM_SORT_ORDER_OLDEST = 'oldest-first';
|
||||
export const doesPathOfferSort = (pathname: string) =>
|
||||
pathname === PATH_ROOT ||
|
||||
pathname.startsWith(PATH_GRID) ||
|
||||
pathname.startsWith(PATH_FEED);
|
||||
pathname.startsWith(PATH_FULL);
|
||||
|
||||
// Feeds
|
||||
export const PATH_SITEMAP = '/sitemap.xml';
|
||||
@ -104,7 +104,7 @@ export const PATHS_ADMIN = [
|
||||
export const PATHS_TO_CACHE = [
|
||||
PATH_ROOT,
|
||||
PATH_GRID,
|
||||
PATH_FEED,
|
||||
PATH_FULL,
|
||||
PATH_OG,
|
||||
PATH_PHOTO_DYNAMIC,
|
||||
PATH_CAMERA_DYNAMIC,
|
||||
@ -154,7 +154,7 @@ export const pathForPhoto = ({
|
||||
let prefix = PREFIX_PHOTO;
|
||||
|
||||
if (typeof photo !== 'string' && photo.hidden) {
|
||||
prefix = pathForTag(TAG_HIDDEN);
|
||||
prefix = pathForTag(TAG_PRIVATE);
|
||||
} else if (recent) {
|
||||
prefix = PREFIX_RECENTS;
|
||||
} else if (year) {
|
||||
@ -231,13 +231,19 @@ export const pathForRecentsImage = () =>
|
||||
pathForImage(PREFIX_RECENTS);
|
||||
|
||||
// Absolute paths
|
||||
export const ABSOLUTE_PATH_FOR_FEED_JSON =
|
||||
export const ABSOLUTE_PATH_GRID =
|
||||
`${getBaseUrl()}${PATH_GRID}`;
|
||||
|
||||
export const ABSOLUTE_PATH_FULL =
|
||||
`${getBaseUrl()}${PATH_FULL}`;
|
||||
|
||||
export const ABSOLUTE_PATH_FEED_JSON =
|
||||
`${getBaseUrl()}${PATH_FEED_JSON}`;
|
||||
|
||||
export const ABSOLUTE_PATH_FOR_RSS_XML =
|
||||
export const ABSOLUTE_PATH_RSS_XML =
|
||||
`${getBaseUrl()}${PATH_RSS_XML}`;
|
||||
|
||||
export const ABSOLUTE_PATH_FOR_HOME_IMAGE =
|
||||
export const ABSOLUTE_PATH_HOME_IMAGE =
|
||||
`${getBaseUrl()}/home-image`;
|
||||
|
||||
export const absolutePathForPhoto = (
|
||||
@ -374,13 +380,13 @@ export const isPathRoot = (pathname?: string) =>
|
||||
export const isPathGrid = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_GRID);
|
||||
|
||||
export const isPathFeed = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_FEED);
|
||||
export const isPathFull = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_FULL);
|
||||
|
||||
export const isPathTopLevel = (pathname?: string) =>
|
||||
isPathRoot(pathname)||
|
||||
isPathGrid(pathname) ||
|
||||
isPathFeed(pathname);
|
||||
isPathFull(pathname);
|
||||
|
||||
export const isPathSignIn = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_SIGN_IN);
|
||||
@ -406,7 +412,7 @@ export const isPathAdminInfo = (pathname?: string) =>
|
||||
|
||||
export const isPathProtected = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_ADMIN) ||
|
||||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)) ||
|
||||
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
|
||||
checkPathPrefix(pathname, PATH_OG);
|
||||
|
||||
export const getPathComponents = (pathname = ''): {
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
PATH_ADMIN_RECIPES,
|
||||
PATH_ADMIN_TAGS,
|
||||
PATH_ADMIN_UPLOADS,
|
||||
PATH_FEED_INFERRED,
|
||||
PATH_FULL_INFERRED,
|
||||
PATH_GRID_INFERRED,
|
||||
PATH_SIGN_IN,
|
||||
pathForCamera,
|
||||
@ -50,10 +50,10 @@ import PhotoDate from '@/photo/PhotoDate';
|
||||
import PhotoSmall from '@/photo/PhotoSmall';
|
||||
import { FaCheck } from 'react-icons/fa6';
|
||||
import {
|
||||
addHiddenToTags,
|
||||
addPrivateToTags,
|
||||
formatTag,
|
||||
isTagFavs,
|
||||
isTagHidden,
|
||||
isTagPrivate,
|
||||
limitTagsByCount,
|
||||
} from '@/tag';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
@ -84,7 +84,6 @@ import useVisualViewportHeight from '@/utility/useVisualViewport';
|
||||
import useMaskedScroll from '../components/useMaskedScroll';
|
||||
import { labelForFilm } from '@/film';
|
||||
import IconFavs from '@/components/icons/IconFavs';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import IconRecents from '@/components/icons/IconRecents';
|
||||
@ -311,12 +310,12 @@ export default function CommandKClient({
|
||||
, [_years, queryLive]);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const tagsIncludingHidden = photosCountHidden > 0
|
||||
? addHiddenToTags(_tags, photosCountHidden)
|
||||
const tagsIncludingPrivate = photosCountHidden > 0
|
||||
? addPrivateToTags(_tags, photosCountHidden)
|
||||
: _tags;
|
||||
return HIDE_TAGS_WITH_ONE_PHOTO
|
||||
? limitTagsByCount(tagsIncludingHidden, 2, queryLive)
|
||||
: tagsIncludingHidden;
|
||||
? limitTagsByCount(tagsIncludingPrivate, 2, queryLive)
|
||||
: tagsIncludingPrivate;
|
||||
}, [_tags, photosCountHidden, queryLive]);
|
||||
|
||||
const categorySections: CommandKSection[] = useMemo(() =>
|
||||
@ -380,10 +379,10 @@ export default function CommandKClient({
|
||||
className="translate-y-[-0.5px]"
|
||||
highlight
|
||||
/>}
|
||||
{isTagHidden(tag) &&
|
||||
<IconHidden
|
||||
size={15}
|
||||
className="translate-y-[-0.5px]"
|
||||
{isTagPrivate(tag) &&
|
||||
<IconLock
|
||||
size={12}
|
||||
className="text-dim translate-y-[-0.5px]"
|
||||
/>}
|
||||
</span>,
|
||||
annotation: formatCount(count),
|
||||
@ -504,11 +503,11 @@ export default function CommandKClient({
|
||||
});
|
||||
}
|
||||
|
||||
const pageFeed: CommandKItem = {
|
||||
const pageFull: CommandKItem = {
|
||||
label: GRID_HOMEPAGE_ENABLED
|
||||
? appText.nav.feed
|
||||
: `${appText.nav.feed} (${appText.nav.home})`,
|
||||
path: PATH_FEED_INFERRED,
|
||||
? appText.nav.full
|
||||
: `${appText.nav.full} (${appText.nav.home})`,
|
||||
path: PATH_FULL_INFERRED,
|
||||
};
|
||||
|
||||
const pageGrid: CommandKItem = {
|
||||
@ -519,8 +518,8 @@ export default function CommandKClient({
|
||||
};
|
||||
|
||||
const pageItems: CommandKItem[] = GRID_HOMEPAGE_ENABLED
|
||||
? [pageGrid, pageFeed]
|
||||
: [pageFeed, pageGrid];
|
||||
? [pageGrid, pageFull]
|
||||
: [pageFull, pageGrid];
|
||||
|
||||
const sectionPages: CommandKSection = {
|
||||
heading: 'Pages',
|
||||
|
||||
@ -2,10 +2,13 @@ import clsx from 'clsx/lite';
|
||||
import { InputHTMLAttributes, ReactNode, RefObject } from 'react';
|
||||
import { ImCheckmark } from 'react-icons/im';
|
||||
|
||||
const SIZE = 'size-4.5';
|
||||
|
||||
const boxStyles = clsx(
|
||||
'relative',
|
||||
'inline-flex items-center justify-center',
|
||||
'size-5 rounded-md border',
|
||||
'rounded-md border',
|
||||
SIZE,
|
||||
);
|
||||
|
||||
export default function Checkbox({
|
||||
@ -22,7 +25,7 @@ export default function Checkbox({
|
||||
<span
|
||||
className={clsx(
|
||||
'relative inline-flex items-center justify-center',
|
||||
'size-5',
|
||||
SIZE,
|
||||
props.readOnly
|
||||
? 'cursor-not-allowed'
|
||||
: 'group-has-active:opacity-70',
|
||||
@ -40,7 +43,7 @@ export default function Checkbox({
|
||||
: 'bg-black',
|
||||
)}>
|
||||
<ImCheckmark
|
||||
size={12}
|
||||
size={11}
|
||||
className={clsx(
|
||||
'text-white',
|
||||
props.readOnly && 'dark:text-gray-400',
|
||||
|
||||
@ -10,6 +10,7 @@ import { FiChevronDown } from 'react-icons/fi';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import Checkbox from './Checkbox';
|
||||
import ResponsiveText from './primitives/ResponsiveText';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export default function FieldSetWithStatus({
|
||||
id: _id,
|
||||
@ -17,6 +18,7 @@ export default function FieldSetWithStatus({
|
||||
icon,
|
||||
note,
|
||||
noteShort,
|
||||
tooltip,
|
||||
error,
|
||||
value,
|
||||
isModified,
|
||||
@ -45,6 +47,7 @@ export default function FieldSetWithStatus({
|
||||
icon?: ReactNode
|
||||
note?: string
|
||||
noteShort?: string
|
||||
tooltip?: string
|
||||
error?: string
|
||||
value: string
|
||||
isModified?: boolean
|
||||
@ -116,7 +119,7 @@ export default function FieldSetWithStatus({
|
||||
// For managing checkbox active state
|
||||
'group',
|
||||
'space-y-1',
|
||||
type === 'checkbox' && 'flex items-center gap-3',
|
||||
type === 'checkbox' && 'flex items-center gap-2',
|
||||
className,
|
||||
)}>
|
||||
{!hideLabel &&
|
||||
@ -124,17 +127,28 @@ export default function FieldSetWithStatus({
|
||||
htmlFor={id}
|
||||
className={clsx(
|
||||
'inline-flex flex-wrap gap-x-2 items-center select-none',
|
||||
type === 'checkbox' && 'order-2 m-0',
|
||||
type === 'checkbox' && 'order-2 m-0 translate-y-[0.25px]',
|
||||
type === 'checkbox' && readOnly &&
|
||||
'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-x-1.5">
|
||||
{icon && <span
|
||||
className="inline-flex items-center justify-center w-4"
|
||||
>
|
||||
<span className="inline-flex items-center gap-x-[5px]">
|
||||
{icon &&
|
||||
<span className={clsx(
|
||||
'inline-flex items-center justify-center w-4 shrink-0',
|
||||
)}>
|
||||
{icon}
|
||||
</span>}
|
||||
<span className="truncate">
|
||||
{label}
|
||||
</span>
|
||||
{tooltip &&
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
classNameTrigger="translate-y-[-1.5px] text-dim"
|
||||
supportMobile
|
||||
/>}
|
||||
</span>
|
||||
{note && !error &&
|
||||
<ResponsiveText
|
||||
className="text-gray-400 dark:text-gray-600"
|
||||
|
||||
@ -110,8 +110,10 @@ export default function EntityLink({
|
||||
setIsLoading={setIsLoading}
|
||||
>
|
||||
<LabeledIcon {...{
|
||||
icon: (hasBadgeIcon && !useForHover) ? undefined : icon,
|
||||
iconWide: (hasBadgeIcon && !useForHover) ? undefined : iconWide,
|
||||
icon:
|
||||
(badged && hasBadgeIcon && !useForHover) ? undefined : icon,
|
||||
iconWide:
|
||||
(badged && hasBadgeIcon && !useForHover) ? undefined : iconWide,
|
||||
prefetch,
|
||||
title,
|
||||
type: useForHover ? 'icon-first' : type,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
const INTRINSIC_WIDTH = 28;
|
||||
const INTRINSIC_HEIGHT = 24;
|
||||
|
||||
export default function IconFeed({
|
||||
export default function IconFull({
|
||||
width = INTRINSIC_WIDTH,
|
||||
includeTitle = true,
|
||||
className,
|
||||
@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx/lite';
|
||||
import { IconBaseProps } from 'react-icons';
|
||||
import { AiOutlineEyeInvisible, AiOutlineEye } from 'react-icons/ai';
|
||||
|
||||
@ -7,6 +8,8 @@ export default function IconHidden({
|
||||
}: IconBaseProps & {
|
||||
visible?: boolean
|
||||
}) {
|
||||
// Flip so slash goes left to right
|
||||
props.className = clsx('-scale-x-100', props.className);
|
||||
return visible
|
||||
? <AiOutlineEye {...props} />
|
||||
: <AiOutlineEyeInvisible {...props} />;
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
import { IconBaseProps } from 'react-icons';
|
||||
import { BiLockAlt } from 'react-icons/bi';
|
||||
import { BiLockAlt, BiLockOpenAlt } from 'react-icons/bi';
|
||||
import { FaLock, FaLockOpen } from 'react-icons/fa';
|
||||
import { FiLock } from 'react-icons/fi';
|
||||
|
||||
export default function IconLock({
|
||||
solid,
|
||||
narrow,
|
||||
open,
|
||||
...props
|
||||
}: IconBaseProps & { narrow?: boolean }) {
|
||||
return narrow
|
||||
? <BiLockAlt {...props} />
|
||||
: <FiLock {...props} />;
|
||||
}: IconBaseProps & { solid?: boolean, narrow?: boolean, open?: boolean }) {
|
||||
if (solid) {
|
||||
return open ? <FaLockOpen {...props} /> : <FaLock {...props} />;
|
||||
} else if (narrow) {
|
||||
return open ? <BiLockOpenAlt {...props} /> : <BiLockAlt {...props} />;
|
||||
} else {
|
||||
return open ? <FiLock {...props} /> : <FiLock {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,32 +1,43 @@
|
||||
import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo';
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { PhotoQueryOptions } from '../photo/db';
|
||||
import {
|
||||
getNextImageUrlForRequest,
|
||||
NextImageSize,
|
||||
} from '@/platforms/next-image';
|
||||
INFINITE_SCROLL_FULL_INITIAL,
|
||||
INFINITE_SCROLL_GRID_INITIAL,
|
||||
} from '../photo';
|
||||
import { SortBy } from '../photo/db/sort';
|
||||
import { FEED_PHOTO_REQUEST_LIMIT } from './programmatic';
|
||||
|
||||
export const FEED_PHOTO_REQUEST_LIMIT = 40;
|
||||
const FEED_BASE_QUERY_OPTIONS: PhotoQueryOptions = {
|
||||
excludeFromFeeds: true,
|
||||
};
|
||||
|
||||
export const FEED_PHOTO_WIDTH_SMALL = 200;
|
||||
export const FEED_PHOTO_WIDTH_MEDIUM = 640;
|
||||
export const FEED_PHOTO_WIDTH_LARGE = 1200;
|
||||
// PAGE FEED QUERY OPTIONS
|
||||
|
||||
export interface FeedMedia {
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export const generateFeedMedia = (
|
||||
photo: Photo,
|
||||
size: NextImageSize,
|
||||
): FeedMedia => ({
|
||||
url: getNextImageUrlForRequest({ imageUrl: photo.url, size }),
|
||||
width: size,
|
||||
height: Math.round(size / photo.aspectRatio),
|
||||
export const getFeedQueryOptions = ({
|
||||
isGrid,
|
||||
sortBy = USER_DEFAULT_SORT_OPTIONS.sortBy,
|
||||
sortWithPriority = USER_DEFAULT_SORT_OPTIONS.sortWithPriority,
|
||||
}: {
|
||||
isGrid: boolean,
|
||||
sortBy?: SortBy,
|
||||
sortWithPriority?: boolean,
|
||||
}): PhotoQueryOptions => ({
|
||||
...FEED_BASE_QUERY_OPTIONS,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
limit: isGrid
|
||||
? INFINITE_SCROLL_GRID_INITIAL
|
||||
: INFINITE_SCROLL_FULL_INITIAL,
|
||||
});
|
||||
|
||||
export const getCoreFeedFields = (photo: Photo) => ({
|
||||
id: photo.id,
|
||||
title: titleForPhoto(photo),
|
||||
description: descriptionForPhoto(photo, true),
|
||||
});
|
||||
export const FEED_META_QUERY_OPTIONS: PhotoQueryOptions = {
|
||||
...FEED_BASE_QUERY_OPTIONS,
|
||||
};
|
||||
|
||||
// PROGRAMMATIC FEED QUERY OPTIONS
|
||||
|
||||
export const PROGRAMMATIC_QUERY_OPTIONS: PhotoQueryOptions = {
|
||||
...FEED_BASE_QUERY_OPTIONS,
|
||||
sortBy: 'createdAt',
|
||||
limit: FEED_PHOTO_REQUEST_LIMIT,
|
||||
};
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
FeedMedia,
|
||||
generateFeedMedia,
|
||||
getCoreFeedFields,
|
||||
} from '.';
|
||||
} from './programmatic';
|
||||
import { formatDateFromPostgresString } from '@/utility/date';
|
||||
import { Photo } from '@/photo';
|
||||
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
|
||||
|
||||
32
src/feed/programmatic.ts
Normal file
32
src/feed/programmatic.ts
Normal 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),
|
||||
});
|
||||
@ -5,8 +5,8 @@ import {
|
||||
FeedMedia,
|
||||
generateFeedMedia,
|
||||
getCoreFeedFields,
|
||||
} from '.';
|
||||
import { ABSOLUTE_PATH_FOR_RSS_XML, absolutePathForPhoto } from '@/app/paths';
|
||||
} from './programmatic';
|
||||
import { ABSOLUTE_PATH_RSS_XML, absolutePathForPhoto } from '@/app/paths';
|
||||
import { formatDate } from '@/utility/date';
|
||||
import { formatStringForXml } from '@/utility/string';
|
||||
import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config';
|
||||
@ -67,7 +67,7 @@ export const formatFeedRssXml = (photos: Photo[]) =>
|
||||
<channel>
|
||||
<title>${META_TITLE}</title>
|
||||
<atom:link
|
||||
href="${ABSOLUTE_PATH_FOR_RSS_XML}"
|
||||
href="${ABSOLUTE_PATH_RSS_XML}"
|
||||
rel="self"
|
||||
type="application/rss+xml"
|
||||
/>
|
||||
|
||||
@ -44,7 +44,7 @@ export const TEXT: I18N = {
|
||||
},
|
||||
nav: {
|
||||
home: 'হোম',
|
||||
feed: 'ফিড',
|
||||
full: 'সম্পূর্ণ',
|
||||
grid: 'গ্রিড',
|
||||
admin: 'অ্যাডমিন',
|
||||
search: 'সার্চ',
|
||||
@ -103,8 +103,8 @@ export const TEXT: I18N = {
|
||||
edit: 'এডিট',
|
||||
favorite: 'পছন্দ',
|
||||
unfavorite: 'পছন্দ অপসারণ',
|
||||
hide: 'লুকান',
|
||||
unhide: 'দেখান',
|
||||
private: 'ব্যক্তিগত করুন',
|
||||
public: 'সর্বজনীন করুন',
|
||||
download: 'ডাউনলোড',
|
||||
sync: 'সিঙ্ক',
|
||||
delete: 'ডিলিট',
|
||||
|
||||
@ -42,7 +42,7 @@ export const TEXT = {
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
feed: 'Feed',
|
||||
full: 'Full',
|
||||
grid: 'Grid',
|
||||
admin: 'Admin',
|
||||
search: 'Search',
|
||||
@ -101,8 +101,8 @@ export const TEXT = {
|
||||
edit: 'Edit',
|
||||
favorite: 'Favorite',
|
||||
unfavorite: 'Unfavorite',
|
||||
hide: 'Hide',
|
||||
unhide: 'Unhide',
|
||||
private: 'Make Private',
|
||||
public: 'Make Public',
|
||||
download: 'Download',
|
||||
sync: 'Sync',
|
||||
delete: 'Delete',
|
||||
|
||||
@ -43,7 +43,7 @@ export const TEXT: I18N = {
|
||||
},
|
||||
nav: {
|
||||
home: 'Beranda',
|
||||
feed: 'Umpan',
|
||||
full: 'Lengkap',
|
||||
grid: 'Grid',
|
||||
admin: 'Admin',
|
||||
search: 'Cari',
|
||||
@ -102,8 +102,8 @@ export const TEXT: I18N = {
|
||||
edit: 'Edit',
|
||||
favorite: 'Favorit',
|
||||
unfavorite: 'Hapus dari Favorit',
|
||||
hide: 'Sembunyikan',
|
||||
unhide: 'Tampilkan',
|
||||
private: 'Buat Privat',
|
||||
public: 'Buat Publik',
|
||||
download: 'Unduh',
|
||||
sync: 'Sinkronkan',
|
||||
delete: 'Hapus',
|
||||
|
||||
@ -43,7 +43,7 @@ export const TEXT: I18N = {
|
||||
},
|
||||
nav: {
|
||||
home: 'Início',
|
||||
feed: 'Feed',
|
||||
full: 'Completo',
|
||||
grid: 'Grade',
|
||||
admin: 'Menu de administrador',
|
||||
search: 'Pesquisar',
|
||||
@ -102,8 +102,8 @@ export const TEXT: I18N = {
|
||||
edit: 'Editar',
|
||||
favorite: 'Favoritar',
|
||||
unfavorite: 'Remover dos favoritos',
|
||||
hide: 'Ocultar',
|
||||
unhide: 'Mostrar',
|
||||
private: 'Tornar Privado',
|
||||
public: 'Tornar Público',
|
||||
download: 'Baixar',
|
||||
sync: 'Sincronizar',
|
||||
delete: 'Excluir',
|
||||
|
||||
@ -43,7 +43,7 @@ export const TEXT: I18N = {
|
||||
},
|
||||
nav: {
|
||||
home: 'Início',
|
||||
feed: 'Feed',
|
||||
full: 'Completo',
|
||||
grid: 'Grade',
|
||||
admin: 'Menu de administração',
|
||||
search: 'Pesquisar',
|
||||
@ -102,8 +102,8 @@ export const TEXT: I18N = {
|
||||
edit: 'Editar',
|
||||
favorite: 'Favoritar',
|
||||
unfavorite: 'Remover dos favoritos',
|
||||
hide: 'Ocultar',
|
||||
unhide: 'Mostrar',
|
||||
private: 'Tornar Privado',
|
||||
public: 'Tornar Público',
|
||||
download: 'Descarregar',
|
||||
sync: 'Sincronizar',
|
||||
delete: 'Excluir',
|
||||
|
||||
@ -43,7 +43,7 @@ export const TEXT: I18N = {
|
||||
},
|
||||
nav: {
|
||||
home: '首页',
|
||||
feed: '动态',
|
||||
full: '完整',
|
||||
grid: '网格',
|
||||
admin: '管理',
|
||||
search: '搜索',
|
||||
@ -102,8 +102,8 @@ export const TEXT: I18N = {
|
||||
edit: '编辑',
|
||||
favorite: '收藏',
|
||||
unfavorite: '取消收藏',
|
||||
hide: '隐藏',
|
||||
unhide: '取消隐藏',
|
||||
private: '设为私密',
|
||||
public: '设为公开',
|
||||
download: '下载',
|
||||
sync: '同步',
|
||||
delete: '删除',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Photo } from '../photo';
|
||||
import IconFeed from '@/components/icons/IconFeed';
|
||||
import IconFull from '@/components/icons/IconFull';
|
||||
import IconGrid from '@/components/icons/IconGrid';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
@ -66,7 +66,7 @@ export default function TemplateImageResponse({
|
||||
color: '#333',
|
||||
borderRight: '2px solid #333',
|
||||
}}>
|
||||
<IconFeed includeTitle={false} width={80} />
|
||||
<IconFull includeTitle={false} width={80} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@ -35,6 +35,7 @@ export default function InfinitePhotoScroll({
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
camera,
|
||||
lens,
|
||||
tag,
|
||||
@ -50,6 +51,7 @@ export default function InfinitePhotoScroll({
|
||||
itemsPerPage: number
|
||||
sortBy?: SortBy
|
||||
sortWithPriority?: boolean
|
||||
excludeFromFeeds?: boolean
|
||||
cacheKey: string
|
||||
wrapMoreButtonInGrid?: boolean
|
||||
useCachedPhotos?: boolean
|
||||
@ -77,6 +79,7 @@ export default function InfinitePhotoScroll({
|
||||
offset: initialOffset + getSizeFromKey(keyWithSize) * itemsPerPage,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
limit: itemsPerPage,
|
||||
hidden: includeHiddenPhotos ? 'include' : 'exclude',
|
||||
camera,
|
||||
@ -90,6 +93,7 @@ export default function InfinitePhotoScroll({
|
||||
useCachedPhotos,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
initialOffset,
|
||||
itemsPerPage,
|
||||
includeHiddenPhotos,
|
||||
|
||||
@ -7,8 +7,8 @@ import PhotoGrid from './PhotoGrid';
|
||||
import TagHeader from '@/tag/TagHeader';
|
||||
import CameraHeader from '@/camera/CameraHeader';
|
||||
import FilmHeader from '@/film/FilmHeader';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import HiddenHeader from '@/tag/HiddenHeader';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
import PrivateHeader from '@/tag/PrivateHeader';
|
||||
import FocalLengthHeader from '@/focal/FocalLengthHeader';
|
||||
import PhotoHeader from './PhotoHeader';
|
||||
import RecipeHeader from '@/recipe/RecipeHeader';
|
||||
@ -48,8 +48,8 @@ export default function PhotoDetailPage({
|
||||
let customHeader: ReactNode | undefined;
|
||||
|
||||
if (tag) {
|
||||
customHeader = tag === TAG_HIDDEN
|
||||
? <HiddenHeader
|
||||
customHeader = tag === TAG_PRIVATE
|
||||
? <PrivateHeader
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
indexNumber={indexNumber}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import {
|
||||
INFINITE_SCROLL_FEED_MULTIPLE,
|
||||
INFINITE_SCROLL_FULL_MULTIPLE,
|
||||
Photo,
|
||||
} from '.';
|
||||
import PhotosLarge from './PhotosLarge';
|
||||
import PhotosLargeInfinite from './PhotosLargeInfinite';
|
||||
import { SortBy } from './db/sort';
|
||||
|
||||
export default function PhotoFeedPage({
|
||||
export default function PhotoFullPage({
|
||||
photos,
|
||||
photosCount,
|
||||
sortBy,
|
||||
@ -25,7 +25,7 @@ export default function PhotoFeedPage({
|
||||
sortBy={sortBy}
|
||||
sortWithPriority={sortWithPriority}
|
||||
initialOffset={photos.length}
|
||||
itemsPerPage={INFINITE_SCROLL_FEED_MULTIPLE}
|
||||
itemsPerPage={INFINITE_SCROLL_FULL_MULTIPLE}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
@ -15,6 +15,7 @@ export default function PhotoGridContainer({
|
||||
count,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
animateOnFirstLoadOnly,
|
||||
header,
|
||||
sidebar,
|
||||
@ -25,6 +26,7 @@ export default function PhotoGridContainer({
|
||||
count: number
|
||||
sortBy?: SortBy
|
||||
sortWithPriority?: boolean
|
||||
excludeFromFeeds?: boolean
|
||||
header?: ReactNode
|
||||
sidebar?: ReactNode
|
||||
} & ComponentProps<typeof PhotoGrid>) {
|
||||
@ -61,6 +63,7 @@ export default function PhotoGridContainer({
|
||||
initialOffset: photos.length,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
...categories,
|
||||
canStart: shouldAnimateDynamicItems,
|
||||
animateOnFirstLoadOnly,
|
||||
|
||||
@ -11,6 +11,7 @@ export default function PhotoGridInfinite({
|
||||
initialOffset,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
canStart,
|
||||
animateOnFirstLoadOnly,
|
||||
canSelect,
|
||||
@ -20,6 +21,7 @@ export default function PhotoGridInfinite({
|
||||
initialOffset: number
|
||||
sortBy?: SortBy
|
||||
sortWithPriority?: boolean
|
||||
excludeFromFeeds?: boolean
|
||||
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
|
||||
return (
|
||||
<InfinitePhotoScroll
|
||||
@ -28,6 +30,7 @@ export default function PhotoGridInfinite({
|
||||
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
|
||||
sortBy={sortBy}
|
||||
sortWithPriority={sortWithPriority}
|
||||
excludeFromFeeds={excludeFromFeeds}
|
||||
{...categories}
|
||||
>
|
||||
{({ photos, onLastPhotoVisible }) =>
|
||||
|
||||
@ -42,6 +42,7 @@ export default function PhotoGridPageClient({
|
||||
count={photosCount}
|
||||
sortBy={sortBy}
|
||||
sortWithPriority={sortWithPriority}
|
||||
excludeFromFeeds
|
||||
prioritizeInitialPhotos
|
||||
sidebar={
|
||||
<MaskedScroll
|
||||
|
||||
@ -4,12 +4,17 @@ import PhotoCamera from '@/camera/PhotoCamera';
|
||||
import HeaderList from '@/components/HeaderList';
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { photoQuantityText } from '.';
|
||||
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
|
||||
import {
|
||||
TAG_FAVS,
|
||||
TAG_PRIVATE,
|
||||
addPrivateToTags,
|
||||
limitTagsByCount,
|
||||
} from '@/tag';
|
||||
import PhotoFilm from '@/film/PhotoFilm';
|
||||
import FavsTag from '../tag/FavsTag';
|
||||
import PhotoFavs from '../tag/PhotoFavs';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import HiddenTag from '@/tag/HiddenTag';
|
||||
import PhotoPrivate from '@/tag/PhotoPrivate';
|
||||
import {
|
||||
CATEGORY_VISIBILITY,
|
||||
HIDE_TAGS_WITH_ONE_PHOTO,
|
||||
@ -96,7 +101,7 @@ export default function PhotoGridSidebar({
|
||||
const { photosCountHidden } = useAppState();
|
||||
|
||||
const tagsIncludingHidden = useMemo(() =>
|
||||
addHiddenToTags(tags, photosCountHidden)
|
||||
addPrivateToTags(tags, photosCountHidden)
|
||||
, [tags, photosCountHidden]);
|
||||
|
||||
const recentsContent = recents.length > 0
|
||||
@ -196,7 +201,7 @@ export default function PhotoGridSidebar({
|
||||
.map(({ tag, count }) => {
|
||||
switch (tag) {
|
||||
case TAG_FAVS:
|
||||
return <FavsTag
|
||||
return <PhotoFavs
|
||||
key={TAG_FAVS}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
@ -204,9 +209,9 @@ export default function PhotoGridSidebar({
|
||||
contrast="low"
|
||||
badged
|
||||
/>;
|
||||
case TAG_HIDDEN:
|
||||
return <HiddenTag
|
||||
key={TAG_HIDDEN}
|
||||
case TAG_PRIVATE:
|
||||
return <PhotoPrivate
|
||||
key={TAG_PRIVATE}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
prefetch={false}
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
deletePhotoAction,
|
||||
syncPhotoAction,
|
||||
toggleFavoritePhotoAction,
|
||||
toggleHidePhotoAction,
|
||||
togglePrivatePhotoAction,
|
||||
} from './actions';
|
||||
import { isPhotoFav } from '@/tag';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
@ -68,7 +68,7 @@ export default function PhotoPrevNextActions({
|
||||
}, [photo?.id]);
|
||||
|
||||
const toggleHidden = useCallback(() => {
|
||||
if (photo?.id) { return toggleHidePhotoAction(photo.id); }
|
||||
if (photo?.id) { return togglePrivatePhotoAction(photo.id); }
|
||||
}, [photo?.id]);
|
||||
|
||||
const navigateToPhotoEdit = useNavigateOrRunActionWithToast({
|
||||
@ -168,7 +168,7 @@ export default function PhotoPrevNextActions({
|
||||
unfavoritePhoto();
|
||||
}
|
||||
break;
|
||||
case KEY_COMMANDS.toggleHide:
|
||||
case KEY_COMMANDS.togglePrivate:
|
||||
if (isUserSignedIn && photo) {
|
||||
if (photo.hidden) {
|
||||
unhidePhoto();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { PATH_FEED_INFERRED } from '@/app/paths';
|
||||
import { PATH_FULL_INFERRED } from '@/app/paths';
|
||||
import InfinitePhotoScroll from './InfinitePhotoScroll';
|
||||
import PhotosLarge from './PhotosLarge';
|
||||
import { SortBy } from './db/sort';
|
||||
@ -17,7 +17,7 @@ export default function PhotosLargeInfinite({
|
||||
}) {
|
||||
return (
|
||||
<InfinitePhotoScroll
|
||||
cacheKey={`page-${PATH_FEED_INFERRED}`}
|
||||
cacheKey={`page-${PATH_FULL_INFERRED}`}
|
||||
initialOffset={initialOffset}
|
||||
itemsPerPage={itemsPerPage}
|
||||
sortBy={sortBy}
|
||||
|
||||
@ -94,6 +94,7 @@ const addUpload = async ({
|
||||
tags,
|
||||
favorite,
|
||||
hidden,
|
||||
excludeFromFeeds,
|
||||
takenAtLocal,
|
||||
takenAtNaiveLocal,
|
||||
onStreamUpdate,
|
||||
@ -104,6 +105,7 @@ const addUpload = async ({
|
||||
tags?: string
|
||||
favorite?: string
|
||||
hidden?: string
|
||||
excludeFromFeeds?: string
|
||||
takenAtLocal: string
|
||||
takenAtNaiveLocal: string
|
||||
onStreamUpdate?: (
|
||||
@ -147,6 +149,7 @@ const addUpload = async ({
|
||||
title: title || aiTitle,
|
||||
caption,
|
||||
tags: tags || aiTags,
|
||||
excludeFromFeeds,
|
||||
hidden,
|
||||
favorite,
|
||||
semanticDescription,
|
||||
@ -187,6 +190,7 @@ export const addUploadsAction = async ({
|
||||
tags,
|
||||
favorite,
|
||||
hidden,
|
||||
excludeFromFeeds,
|
||||
takenAtLocal,
|
||||
takenAtNaiveLocal,
|
||||
}: Omit<
|
||||
@ -231,6 +235,7 @@ export const addUploadsAction = async ({
|
||||
tags,
|
||||
favorite,
|
||||
hidden,
|
||||
excludeFromFeeds,
|
||||
takenAtLocal,
|
||||
takenAtNaiveLocal,
|
||||
onStreamUpdate: streamUpdate,
|
||||
@ -317,7 +322,7 @@ export const toggleFavoritePhotoAction = async (
|
||||
}
|
||||
});
|
||||
|
||||
export const toggleHidePhotoAction = async (
|
||||
export const togglePrivatePhotoAction = async (
|
||||
photoId: string,
|
||||
redirectPath?: string,
|
||||
) =>
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
PATHS_ADMIN,
|
||||
PATHS_TO_CACHE,
|
||||
PATH_ADMIN,
|
||||
PATH_FEED,
|
||||
PATH_FULL,
|
||||
PATH_GRID,
|
||||
PATH_ROOT,
|
||||
PREFIX_CAMERA,
|
||||
@ -153,7 +153,7 @@ export const revalidatePhoto = (photoId: string) => {
|
||||
revalidatePath(pathForPhoto({ photo: photoId }), 'layout');
|
||||
revalidatePath(PATH_ROOT, 'layout');
|
||||
revalidatePath(PATH_GRID, 'layout');
|
||||
revalidatePath(PATH_FEED, 'layout');
|
||||
revalidatePath(PATH_FULL, 'layout');
|
||||
revalidatePath(PREFIX_TAG, 'layout');
|
||||
revalidatePath(PREFIX_CAMERA, 'layout');
|
||||
revalidatePath(PREFIX_LENS, 'layout');
|
||||
@ -179,11 +179,15 @@ export const getPhotosNearIdCached = (
|
||||
getPhotosNearId,
|
||||
[KEY_PHOTOS, ...getPhotosCacheKeys(args[1])],
|
||||
)(...args).then(({ photos, indexNumber }) => {
|
||||
const [photoId, { limit }] = args;
|
||||
const [photoId, { limit }, excludeFromFeeds] = args;
|
||||
const photo = photos.find(({ id }) => id === photoId);
|
||||
const isPhotoFirst = photos.findIndex(p => p.id === photoId) === 0;
|
||||
return {
|
||||
photo: photo ? parseCachedPhotoDates(photo) : undefined,
|
||||
// Don't show photo in context when excluded from feeds
|
||||
...excludeFromFeeds && photo?.excludeFromFeeds
|
||||
? { photos: [] }
|
||||
: {
|
||||
photos: parseCachedPhotosDates(photos),
|
||||
...limit && {
|
||||
photosGrid: photos.slice(
|
||||
@ -191,6 +195,7 @@ export const getPhotosNearIdCached = (
|
||||
isPhotoFirst ? limit - 1 : limit,
|
||||
),
|
||||
},
|
||||
},
|
||||
indexNumber,
|
||||
};
|
||||
});
|
||||
|
||||
@ -29,6 +29,7 @@ export type PhotoQueryOptions = {
|
||||
takenBefore?: Date
|
||||
takenAfterInclusive?: Date
|
||||
updatedBefore?: Date
|
||||
excludeFromFeeds?: boolean
|
||||
hidden?: 'exclude' | 'include' | 'only'
|
||||
} & Omit<PhotoSetCategory, 'camera' | 'lens'> & {
|
||||
camera?: Partial<Camera>
|
||||
@ -44,6 +45,7 @@ export const getWheresFromOptions = (
|
||||
) => {
|
||||
const {
|
||||
hidden = 'exclude',
|
||||
excludeFromFeeds,
|
||||
takenBefore,
|
||||
takenAfterInclusive,
|
||||
updatedBefore,
|
||||
@ -72,6 +74,9 @@ export const getWheresFromOptions = (
|
||||
break;
|
||||
}
|
||||
|
||||
if (excludeFromFeeds) {
|
||||
wheres.push('exclude_from_feeds IS NOT TRUE');
|
||||
}
|
||||
if (takenBefore) {
|
||||
wheres.push(`taken_at < $${valuesIndex++}`);
|
||||
wheresValues.push(takenBefore.toISOString());
|
||||
|
||||
@ -71,6 +71,13 @@ export const MIGRATIONS: Migration[] = [{
|
||||
END IF;
|
||||
END $$;
|
||||
`,
|
||||
}, {
|
||||
label: '06: Exclude from feeds',
|
||||
fields: ['exclude_from_feeds'],
|
||||
run: () => sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS exclude_from_feeds BOOLEAN DEFAULT FALSE
|
||||
`,
|
||||
}];
|
||||
|
||||
export const migrationForError = (e: any) =>
|
||||
|
||||
@ -69,7 +69,8 @@ const createPhotosTable = () =>
|
||||
priority_order REAL,
|
||||
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
taken_at_naive VARCHAR(255) NOT NULL,
|
||||
hidden BOOLEAN,
|
||||
exclude_from_feeds BOOLEAN DEFAULT FALSE,
|
||||
hidden BOOLEAN DEFAULT FALSE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@ -193,6 +194,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
|
||||
recipe_title,
|
||||
recipe_data,
|
||||
priority_order,
|
||||
exclude_from_feeds,
|
||||
hidden,
|
||||
taken_at,
|
||||
taken_at_naive
|
||||
@ -224,6 +226,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
|
||||
${photo.recipeTitle},
|
||||
${photo.recipeData},
|
||||
${photo.priorityOrder},
|
||||
${photo.excludeFromFeeds},
|
||||
${photo.hidden},
|
||||
${photo.takenAt},
|
||||
${photo.takenAtNaive}
|
||||
@ -258,6 +261,7 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
|
||||
recipe_title=${photo.recipeTitle},
|
||||
recipe_data=${photo.recipeData},
|
||||
priority_order=${photo.priorityOrder || null},
|
||||
exclude_from_feeds=${photo.excludeFromFeeds},
|
||||
hidden=${photo.hidden},
|
||||
taken_at=${photo.takenAt},
|
||||
taken_at_naive=${photo.takenAtNaive},
|
||||
@ -527,6 +531,7 @@ export const getPhotos = async (options: PhotoQueryOptions = {}) =>
|
||||
export const getPhotosNearId = async (
|
||||
photoId: string,
|
||||
options: PhotoQueryOptions,
|
||||
excludeFromFeeds?: boolean,
|
||||
) =>
|
||||
safelyQueryPhotos(async () => {
|
||||
const { limit } = options;
|
||||
@ -561,6 +566,7 @@ export const getPhotosNearId = async (
|
||||
return {
|
||||
photos: rows.map(parsePhotoFromDb),
|
||||
indexNumber,
|
||||
excludeFromFeeds,
|
||||
};
|
||||
});
|
||||
}, `getPhotosNearId: ${photoId}`);
|
||||
|
||||
@ -6,8 +6,8 @@ import {
|
||||
PARAM_SORT_ORDER_OLDEST,
|
||||
PARAM_SORT_TYPE_TAKEN_AT,
|
||||
PARAM_SORT_TYPE_UPLOADED_AT,
|
||||
PATH_FEED,
|
||||
PATH_FEED_INFERRED,
|
||||
PATH_FULL,
|
||||
PATH_FULL_INFERRED,
|
||||
PATH_GRID,
|
||||
PATH_GRID_INFERRED,
|
||||
} from '@/app/paths';
|
||||
@ -84,11 +84,11 @@ export const getSortOptionsFromParams = async (
|
||||
};
|
||||
|
||||
export const getPathSortComponents = (pathname: string) => {
|
||||
const [_, gridOrFeed, sortType, sortOrder] = pathname.split('/');
|
||||
const [_, gridOrFull, sortType, sortOrder] = pathname.split('/');
|
||||
return {
|
||||
gridOrFeed: gridOrFeed || (GRID_HOMEPAGE_ENABLED
|
||||
gridOrFull: gridOrFull || (GRID_HOMEPAGE_ENABLED
|
||||
? 'grid'
|
||||
: 'feed'
|
||||
: 'full'
|
||||
),
|
||||
sortType: sortType || DEFAULT_SORT_TYPE,
|
||||
sortOrder: sortOrder || DEFAULT_SORT_ORDER,
|
||||
@ -101,7 +101,7 @@ const getReversedSortOrder = (sortOrder: string): string =>
|
||||
: PARAM_SORT_ORDER_OLDEST;
|
||||
|
||||
export const getSortConfigFromPath = (pathname: string) => {
|
||||
const { gridOrFeed, sortType, sortOrder } = getPathSortComponents(pathname);
|
||||
const { gridOrFull, sortType, sortOrder } = getPathSortComponents(pathname);
|
||||
const { sortBy } = _getSortOptionsFromParams(sortType, sortOrder);
|
||||
const isSortedByDefault = sortBy === USER_DEFAULT_SORT_BY;
|
||||
const reversedSortOrder = getReversedSortOrder(sortOrder);
|
||||
@ -116,13 +116,13 @@ export const getSortConfigFromPath = (pathname: string) => {
|
||||
pathGrid: isSortedByDefault
|
||||
? PATH_GRID_INFERRED
|
||||
: `${PATH_GRID}/${sortType}/${sortOrder}`,
|
||||
pathFeed: isSortedByDefault
|
||||
? PATH_FEED_INFERRED
|
||||
: `${PATH_FEED}/${sortType}/${sortOrder}`,
|
||||
pathFull: isSortedByDefault
|
||||
? PATH_FULL_INFERRED
|
||||
: `${PATH_FULL}/${sortType}/${sortOrder}`,
|
||||
pathSort: doesReverseSortMatchDefault
|
||||
? gridOrFeed === 'grid'
|
||||
? gridOrFull === 'grid'
|
||||
? PATH_GRID_INFERRED
|
||||
: PATH_FEED_INFERRED
|
||||
: `/${gridOrFeed}/${sortType}/${reversedSortOrder}`,
|
||||
: PATH_FULL_INFERRED
|
||||
: `/${gridOrFull}/${sortType}/${reversedSortOrder}`,
|
||||
};
|
||||
};
|
||||
|
||||
@ -2,16 +2,21 @@ import { ComponentProps } from 'react';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
|
||||
export default function FieldsetHidden(props: Omit<
|
||||
export default function FieldsetExclude(props: Omit<
|
||||
ComponentProps<typeof FieldSetWithStatus>,
|
||||
'label' | 'icon' | 'type'
|
||||
>) {
|
||||
return (
|
||||
<FieldSetWithStatus
|
||||
{...props}
|
||||
label="Hidden"
|
||||
label="Exclude from feeds"
|
||||
type="checkbox"
|
||||
icon={<IconHidden size={17} visible={props.value !== 'true'} />}
|
||||
icon={<IconHidden
|
||||
size={17}
|
||||
className="translate-y-[0.5px]"
|
||||
visible={props.value !== 'true'}
|
||||
/>}
|
||||
tooltip="Do not show on homepage views or RSS"
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
src/photo/form/FieldsetPrivate.tsx
Normal file
22
src/photo/form/FieldsetPrivate.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -45,9 +45,10 @@ import { convertFilmsForForm, Films } from '@/film';
|
||||
import { isMakeFujifilm } from '@/platforms/fujifilm';
|
||||
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
|
||||
import FieldsetFavs from './FieldsetFavs';
|
||||
import FieldsetHidden from './FieldsetHidden';
|
||||
import FieldsetPrivate from './FieldsetPrivate';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import IconAddUpload from '@/components/icons/IconAddUpload';
|
||||
import FieldsetExclude from './FieldsetExclude';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -184,6 +185,16 @@ export default function PhotoForm({
|
||||
onTextContentChange?.(formHasTextContent(formData));
|
||||
}, [onTextContentChange, formData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.hidden === 'true') {
|
||||
setFormData(data => ({
|
||||
...data,
|
||||
excludeFromFeeds: 'false',
|
||||
favorite: 'false',
|
||||
}));
|
||||
}
|
||||
}, [formData.hidden]);
|
||||
|
||||
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
|
||||
switch (key) {
|
||||
case 'title':
|
||||
@ -241,7 +252,7 @@ export default function PhotoForm({
|
||||
}
|
||||
};
|
||||
|
||||
const shouldHideField = (
|
||||
const isFieldHidden = (
|
||||
key: FormFields,
|
||||
hideIfEmpty?: boolean,
|
||||
shouldHide?: FormMeta['shouldHide'],
|
||||
@ -261,6 +272,13 @@ export default function PhotoForm({
|
||||
}
|
||||
};
|
||||
|
||||
const isFieldReadOnly = (key: FormFields) => {
|
||||
return formData.hidden === 'true' && (
|
||||
key === 'excludeFromFeeds' ||
|
||||
key === 'favorite'
|
||||
);
|
||||
};
|
||||
|
||||
const onMatchResults = useCallback((didFindMatchingPhotos: boolean) => {
|
||||
setFormData(data => ({
|
||||
...data,
|
||||
@ -361,7 +379,7 @@ export default function PhotoForm({
|
||||
type,
|
||||
staticValue,
|
||||
}]) => {
|
||||
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
|
||||
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
|
||||
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
|
||||
id: key,
|
||||
label: label + (
|
||||
@ -403,7 +421,7 @@ export default function PhotoForm({
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
required,
|
||||
readOnly,
|
||||
readOnly: readOnly || isFieldReadOnly(key),
|
||||
spellCheck,
|
||||
capitalize,
|
||||
placeholder: loadingMessage && !formData[key]
|
||||
@ -445,8 +463,13 @@ export default function PhotoForm({
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
case 'excludeFromFeeds':
|
||||
return <FieldsetExclude
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
case 'hidden':
|
||||
return <FieldsetHidden
|
||||
return <FieldsetPrivate
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
@ -462,7 +485,7 @@ export default function PhotoForm({
|
||||
{/* Actions */}
|
||||
<div className={clsx(
|
||||
'flex gap-3 sticky bottom-0',
|
||||
'pb-4 md:pb-8 mt-12',
|
||||
'pb-4 md:pb-8 mt-16',
|
||||
)}>
|
||||
<Link
|
||||
className="button"
|
||||
|
||||
@ -182,6 +182,7 @@ const FORM_METADATA = (
|
||||
},
|
||||
priorityOrder: { label: 'priority order' },
|
||||
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
|
||||
excludeFromFeeds: { label: 'exclude from feeds', type: 'checkbox' },
|
||||
hidden: { label: 'hidden', type: 'checkbox' },
|
||||
shouldStripGpsData: {
|
||||
label: 'strip gps data',
|
||||
@ -338,6 +339,7 @@ export const convertFormDataToPhotoDbInsert = (
|
||||
priorityOrder: photoForm.priorityOrder
|
||||
? parseFloat(photoForm.priorityOrder)
|
||||
: undefined,
|
||||
excludeFromFeeds: photoForm.excludeFromFeeds === 'true',
|
||||
hidden: photoForm.hidden === 'true',
|
||||
...generateTakenAtFields(photoForm),
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
SHOW_LENSES,
|
||||
SHOW_RECIPES,
|
||||
} from '@/app/config';
|
||||
import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/app/paths';
|
||||
import { ABSOLUTE_PATH_HOME_IMAGE } from '@/app/paths';
|
||||
import { formatDate, formatDateFromPostgresString } from '@/utility/date';
|
||||
import {
|
||||
formatAperture,
|
||||
@ -25,10 +25,10 @@ import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync';
|
||||
import { AppTextState } from '@/i18n/state';
|
||||
|
||||
// INFINITE SCROLL: FEED
|
||||
export const INFINITE_SCROLL_FEED_INITIAL =
|
||||
// INFINITE SCROLL: FULL
|
||||
export const INFINITE_SCROLL_FULL_INITIAL =
|
||||
process.env.NODE_ENV === 'development' ? 2 : 12;
|
||||
export const INFINITE_SCROLL_FEED_MULTIPLE =
|
||||
export const INFINITE_SCROLL_FULL_MULTIPLE =
|
||||
process.env.NODE_ENV === 'development' ? 2 : 24;
|
||||
|
||||
// INFINITE SCROLL: GRID
|
||||
@ -84,6 +84,7 @@ export interface PhotoDbInsert extends PhotoExif {
|
||||
recipeTitle?: string
|
||||
locationName?: string
|
||||
priorityOrder?: number
|
||||
excludeFromFeeds?: boolean
|
||||
hidden?: boolean
|
||||
takenAt: string
|
||||
takenAtNaive: string
|
||||
@ -198,11 +199,11 @@ export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => {
|
||||
if (photos.length > 0) {
|
||||
return {
|
||||
openGraph: {
|
||||
images: ABSOLUTE_PATH_FOR_HOME_IMAGE,
|
||||
images: ABSOLUTE_PATH_HOME_IMAGE,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
images: ABSOLUTE_PATH_FOR_HOME_IMAGE,
|
||||
images: ABSOLUTE_PATH_HOME_IMAGE,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export const KEY_COMMANDS = {
|
||||
feed: 'F',
|
||||
full: 'F',
|
||||
grid: 'G',
|
||||
admin: 'A',
|
||||
prev: ['J', 'ARROWLEFT'],
|
||||
@ -7,7 +7,7 @@ export const KEY_COMMANDS = {
|
||||
edit: 'E',
|
||||
favorite: 'P',
|
||||
unfavorite: 'X',
|
||||
toggleHide: 'H',
|
||||
togglePrivate: 'M',
|
||||
download: 'D',
|
||||
sync: 'S',
|
||||
search: ['⌘', 'K'],
|
||||
|
||||
@ -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]"
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ import EntityLink, {
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconFavs from '@/components/icons/IconFavs';
|
||||
|
||||
export default function FavsTag(props: EntityLinkExternalProps) {
|
||||
export default function PhotoFavs(props: EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
26
src/tag/PhotoPrivate.tsx
Normal file
26
src/tag/PhotoPrivate.tsx
Normal 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
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { isTagFavs } from '.';
|
||||
import FavsTag from './FavsTag';
|
||||
import PhotoFavs from './PhotoFavs';
|
||||
import { EntityLinkExternalProps } from '@/components/entity/EntityLink';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
@ -18,7 +18,7 @@ export default function PhotoTags({
|
||||
{tags.map(tag =>
|
||||
<Fragment key={tag}>
|
||||
{isTagFavs(tag)
|
||||
? <FavsTag {...{
|
||||
? <PhotoFavs {...{
|
||||
contrast,
|
||||
prefetch,
|
||||
countOnHover: tagCounts[tag],
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Photo, photoQuantityText } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import HiddenTag from './HiddenTag';
|
||||
import PhotoPrivate from './PhotoPrivate';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
export default async function HiddenHeader({
|
||||
export default async function PrivateHeader({
|
||||
photos,
|
||||
selectedPhoto,
|
||||
indexNumber,
|
||||
@ -19,7 +19,7 @@ export default async function HiddenHeader({
|
||||
return (
|
||||
<PhotoHeader
|
||||
key="HiddenHeader"
|
||||
entity={<HiddenTag contrast="high" />}
|
||||
entity={<PhotoPrivate contrast="high" />}
|
||||
entityDescription={photoQuantityText(count, appText, false, false)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
@ -2,7 +2,7 @@ import { Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoTag from './PhotoTag';
|
||||
import { descriptionForTaggedPhotos, isTagFavs } from '.';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import FavsTag from './FavsTag';
|
||||
import PhotoFavs from './PhotoFavs';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
|
||||
@ -26,7 +26,7 @@ export default async function TagHeader({
|
||||
<PhotoHeader
|
||||
tag={tag}
|
||||
entity={isTagFavs(tag)
|
||||
? <FavsTag
|
||||
? <PhotoFavs
|
||||
contrast="high"
|
||||
showHover={false}
|
||||
/>
|
||||
|
||||
@ -20,7 +20,7 @@ import { AppTextState } from '@/i18n/state';
|
||||
|
||||
// Reserved tags
|
||||
export const TAG_FAVS = 'favs';
|
||||
export const TAG_HIDDEN = 'hidden';
|
||||
export const TAG_PRIVATE = 'private';
|
||||
|
||||
type TagWithMeta = { tag: string } & CategoryQueryMeta;
|
||||
|
||||
@ -31,7 +31,7 @@ export const formatTag = (tag?: string) =>
|
||||
|
||||
export const getValidationMessageForTags = (tags?: string) => {
|
||||
const reservedTags = (convertStringToArray(tags) ?? [])
|
||||
.filter(tag => isTagFavs(tag) || isTagHidden(tag))
|
||||
.filter(tag => isTagFavs(tag) || isTagPrivate(tag))
|
||||
.map(tag => tag.toLocaleUpperCase());
|
||||
return reservedTags.length
|
||||
? `Reserved tags: ${reservedTags.join(', ').toLocaleLowerCase()}`
|
||||
@ -135,20 +135,20 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||
export const isPathFavs = (pathname?: string) =>
|
||||
getPathComponents(pathname).tag === TAG_FAVS;
|
||||
|
||||
export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN;
|
||||
export const isTagPrivate = (tag: string) => tag.toLowerCase() === TAG_PRIVATE;
|
||||
|
||||
export const addHiddenToTags = (
|
||||
export const addPrivateToTags = (
|
||||
tags: Tags,
|
||||
countHidden = 0,
|
||||
lastModifiedHidden = new Date(),
|
||||
countPrivate = 0,
|
||||
lastModifiedPrivate = new Date(),
|
||||
) =>
|
||||
countHidden > 0
|
||||
countPrivate > 0
|
||||
? tags
|
||||
.filter(({ tag }) => tag === TAG_FAVS)
|
||||
.concat({
|
||||
tag: TAG_HIDDEN,
|
||||
count: countHidden,
|
||||
lastModified: lastModifiedHidden,
|
||||
tag: TAG_PRIVATE,
|
||||
count: countPrivate,
|
||||
lastModified: lastModifiedPrivate,
|
||||
})
|
||||
.concat(tags
|
||||
.filter(({ tag }) => tag !== TAG_FAVS)
|
||||
@ -176,7 +176,7 @@ export const limitTagsByCount = (
|
||||
tags.filter(({ tag, count }) => (
|
||||
count >= minimumCount ||
|
||||
isTagFavs(tag) ||
|
||||
isTagHidden(tag) ||
|
||||
isTagPrivate(tag) ||
|
||||
(queryToInclude && tag
|
||||
.toLocaleLowerCase()
|
||||
.includes(queryToInclude.toLocaleLowerCase()))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user