From aefda2db19fe0f87b9c4549e008d3d609c4ccc79 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 13 Jul 2025 18:38:33 -0500 Subject: [PATCH] Add section menu to admin app config --- app/admin/configuration/page.tsx | 16 +- src/admin/AdminAppConfigurationClient.tsx | 895 ------------------ src/admin/AdminInfoPage.tsx | 8 +- .../{ => config}/AdminAppConfiguration.tsx | 2 +- .../config/AdminAppConfigurationClient.tsx | 881 +++++++++++++++++ .../AdminAppConfigurationServer.tsx | 0 .../config/AdminAppConfigurationSidebar.tsx | 38 + src/admin/config/index.tsx | 78 ++ src/app/Nav.tsx | 3 +- src/components/AppGrid.tsx | 4 +- src/components/ChecklistGroup.tsx | 17 +- src/components/ScoreCard.tsx | 2 +- src/components/ScoreCardContainer.tsx | 2 +- src/photo/PhotoDetailPage.tsx | 1 - src/photo/PhotoGridContainer.tsx | 1 - src/photo/PhotosEmptyState.tsx | 2 +- src/utility/useHash.ts | 25 + 17 files changed, 1062 insertions(+), 913 deletions(-) delete mode 100644 src/admin/AdminAppConfigurationClient.tsx rename src/admin/{ => config}/AdminAppConfiguration.tsx (100%) create mode 100644 src/admin/config/AdminAppConfigurationClient.tsx rename src/admin/{ => config}/AdminAppConfigurationServer.tsx (100%) create mode 100644 src/admin/config/AdminAppConfigurationSidebar.tsx create mode 100644 src/admin/config/index.tsx create mode 100644 src/utility/useHash.ts diff --git a/app/admin/configuration/page.tsx b/app/admin/configuration/page.tsx index c3eb3ea7..cdbbe23c 100644 --- a/app/admin/configuration/page.tsx +++ b/app/admin/configuration/page.tsx @@ -1,9 +1,21 @@ -import AdminAppConfiguration from '@/admin/AdminAppConfiguration'; +import AdminAppConfiguration from '@/admin/config/AdminAppConfiguration'; +import AdminAppConfigurationSidebar from + '@/admin/config/AdminAppConfigurationSidebar'; import AdminInfoPage from '@/admin/AdminInfoPage'; +import { APP_CONFIGURATION } from '@/app/config'; +import { Suspense } from 'react'; export default function AdminAppConfigurationPage() { + const { areInternalToolsEnabled } = APP_CONFIGURATION; return ( - + + + } + > ); diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx deleted file mode 100644 index 4672aabb..00000000 --- a/src/admin/AdminAppConfigurationClient.tsx +++ /dev/null @@ -1,895 +0,0 @@ -'use client'; - -import { - ComponentProps, - Fragment, - ReactNode, -} from 'react'; -import ChecklistRow from '../components/ChecklistRow'; -import { - BiData, - BiHide, - BiLockAlt, - BiPencil, -} from 'react-icons/bi'; -import { HiOutlineCog, HiSparkles } from 'react-icons/hi'; -import ChecklistGroup from '@/components/ChecklistGroup'; -import { AppConfiguration } from '../app/config'; -import StatusIcon from '@/components/StatusIcon'; -import { labelForStorage } from '@/platforms/storage'; -import { testConnectionsAction } from '@/admin/actions'; -import ErrorNote from '@/components/ErrorNote'; -import { RiSpeedMiniLine } from 'react-icons/ri'; -import SecretGenerator from '../app/SecretGenerator'; -import { PiPaintBrushHousehold } from 'react-icons/pi'; -import { IoMdGrid } from 'react-icons/io'; -import { CgDebug } from 'react-icons/cg'; -import EnvVar from '@/components/EnvVar'; -import AdminLink from './AdminLink'; -import ScoreCardContainer from '@/components/ScoreCardContainer'; -import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category'; -import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai'; -import clsx from 'clsx/lite'; -import Link from 'next/link'; -import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths'; -import { FaRegFolderClosed } from 'react-icons/fa6'; -import IconSort from '@/components/icons/IconSort'; -import { APP_DEFAULT_SORT_BY, SORT_BY_OPTIONS } from '@/photo/db/sort'; - -export default function AdminAppConfigurationClient({ - // Storage - hasDatabase, - isPostgresSslEnabled, - hasVercelPostgres, - hasRedisStorage, - hasStorageProvider, - hasVercelBlobStorage, - hasCloudflareR2Storage, - hasAwsS3Storage, - hasMultipleStorageProviders, - currentStorage, - // Auth - hasAuthSecret, - hasAdminUser, - // Content - locale, - hasLocale, - domain, - hasDomain, - metaTitle, - isMetaTitleConfigured, - metaDescription, - isMetaDescriptionConfigured, - navTitle, - hasNavTitle, - navCaption, - hasNavCaption, - pageAbout, - hasPageAbout, - // AI - hasOpenaiBaseUrl, - isAiTextGenerationEnabled, - aiTextAutoGeneratedFields, - hasAiTextAutoGeneratedFields, - // Performance - isStaticallyOptimized, - arePhotosStaticallyOptimized, - arePhotoOGImagesStaticallyOptimized, - arePhotoCategoriesStaticallyOptimized, - arePhotoCategoryOgImagesStaticallyOptimized, - areOriginalUploadsPreserved, - hasImageQuality, - imageQuality, - isBlurEnabled, - // Categories - hasCategoryVisibility, - categoryVisibility, - showCategoryImageHover, - collapseSidebarCategories, - hideTagsWithOnePhoto, - // Sort - hasDefaultSortBy, - defaultSortBy, - isSortWithPriority, - showSortControl, - // Display - showKeyboardShortcutTooltips, - showExifInfo, - showZoomControls, - showTakenAtTimeHidden, - showSocial, - showRepoLink, - // Grid - isGridHomepageEnabled, - gridAspectRatio, - hasGridAspectRatio, - hasHighGridDensity, - hasGridDensityPreference, - // Design - hasDefaultTheme, - defaultTheme, - arePhotosMatted, - arePhotoMatteColorsConfigured, - matteColor, - matteColorDark, - // Settings - isGeoPrivacyEnabled, - arePublicDownloadsEnabled, - areSiteFeedsEnabled, - isOgTextBottomAligned, - // Internal - areInternalToolsEnabled, - areAdminDebugToolsEnabled, - isAdminDbOptimizeEnabled, - isAdminSqlDebugEnabled, - // Connection status - databaseError, - storageError, - redisError, - aiError, - // Component props - simplifiedView, - isAnalyzingConfiguration, -}: AppConfiguration & - Partial>> & { - simplifiedView?: boolean - isAnalyzingConfiguration?: boolean -}) { - const renderContent = (content?: ReactNode) => content - ?
- {content} -
- : null; - - const renderEnvVars = (variables: string[]) => -
- {variables.map(variable => - )} -
; - - const renderSubStatus = ( - type: ComponentProps['type'], - label: ReactNode, - iconClassName = 'translate-y-[3.5px]', - ) => -
- - - {label} - -
; - - const renderSubStatusWithEnvVar = ( - type: ComponentProps['type'], - variable: string, - ) => - renderSubStatus( - type, - renderEnvVars([variable]), - 'translate-y-[7px]', - ); - - const renderError = ({ - connection, - message, - }: { - connection?: { provider: string, error: string } - message?: string - }) => - - {connection && <> - {connection.provider} connection error: {`"${connection.error}"`} - } - {message} - ; - - const renderLink = (href: string, children?: ReactNode) => - - {children || href} - ; - - return ( - - } - > - - {databaseError && renderError({ - connection: { provider: 'Database', error: databaseError}, - })} - {hasVercelPostgres - ? renderSubStatus('checked', 'Vercel Postgres: connected') - : renderSubStatus('optional', <> - Vercel Postgres: - {' '} - - create store - - {' '} - and connect to project - )} - {hasDatabase && !hasVercelPostgres && - renderSubStatus('checked', <> - Postgres-compatible: connected - {' '} - (SSL {isPostgresSslEnabled ? 'enabled' : 'disabled'}) - )} - - - {storageError && renderError({ - connection: { provider: 'Storage', error: storageError}, - })} -
- {hasVercelBlobStorage - ? renderSubStatus('checked', 'Vercel Blob: connected') - : renderSubStatus('optional', <> - {labelForStorage('vercel-blob')}: - {' '} - - create store - - {' '} - and connect to project - , - )} - {hasCloudflareR2Storage - ? renderSubStatus('checked', 'Cloudflare R2: connected') - : renderSubStatus('optional', <> - {labelForStorage('cloudflare-r2')}: - {' '} - - create/configure bucket - - )} - {hasAwsS3Storage - ? renderSubStatus('checked', 'AWS S3: connected') - : renderSubStatus('optional', <> - {labelForStorage('aws-s3')}: - {' '} - - create/configure bucket - - )} -
-
-
- } - > - - Store auth secret in environment variable: - {!hasAuthSecret && -
- -
} - {renderEnvVars(['AUTH_SECRET'])} -
- - Store admin email/password - {' '} - in environment variables: - {renderEnvVars([ - 'ADMIN_EMAIL', - 'ADMIN_PASSWORD', - ])} - -
- } - > - - {renderContent(locale)} - Store in environment variable - (check README for - {' '} - - supported languages - - ): - {renderEnvVars(['NEXT_PUBLIC_LOCALE'])} - - - {renderContent(domain)} - Store in environment variable - (used in explicit share urls, seen in nav if no title is defined): - {renderEnvVars(['NEXT_PUBLIC_DOMAIN'])} - - - {renderContent(metaTitle)} - Store in environment variable - (seen in search results and browser tab): - {renderEnvVars(['NEXT_PUBLIC_META_TITLE'])} - - {!simplifiedView && <> - - {renderContent(metaDescription)} - Store in environment variable - (seen in search results): - {renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])} - - - {renderContent(navTitle)} - Store in environment variable (replaces domain in top-right nav): - {renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])} - - - {hasNavCaption && renderContent(navCaption)} - Store in environment variable (seen in top-right nav, under title): - {renderEnvVars(['NEXT_PUBLIC_NAV_CAPTION'])} - - - {hasPageAbout && renderContent(pageAbout)} - Store in environment variable (seen in sidebar): - {renderEnvVars(['NEXT_PUBLIC_PAGE_ABOUT'])} - - } - - {!simplifiedView && <> - } - optional - > - - {aiError && renderError({ - connection: { provider: 'OpenAI', error: aiError}, - })} - Store your OpenAI secret key in order to enable AI-generated - text descriptions and optionally leverage an invisible field - called {'"Semantic Description"'} used to support CMD-K search - and improve accessibility: - {renderEnvVars(['OPENAI_SECRET_KEY'])} - - -
- {hasAiTextAutoGeneratedFields && - AI_AUTO_GENERATED_FIELDS_ALL.map(field => - - {renderSubStatus( - aiTextAutoGeneratedFields.includes(field) - ? 'checked' - : 'optional', - field, - )} - )} -
- Comma-separated fields to auto-generate when - uploading photos. Accepted values: title, caption, - tags, description, all, or none - {' '} - (default: {'"title,tags,semantic"'}): - {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} -
- - {redisError && renderError({ - connection: { provider: 'Redis', error: redisError}, - })} - Create Upstash Redis store from storage tab - on Vercel dashboard and connect to this project - to enable rate limiting - - - Store base URL in environment variable to use - alternate OpenAI-compatible providers: - {renderEnvVars(['OPENAI_BASE_URL'])} - -
- } - optional - > - - Set environment variable to {'"1"'} to make site more responsive - by enabling static optimization - (i.e., rendering pages and images at build time): -
- {renderSubStatusWithEnvVar( - arePhotosStaticallyOptimized ? 'checked' : 'optional', - 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS', - )} - {renderSubStatusWithEnvVar( - arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional', - 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES', - )} - {renderSubStatusWithEnvVar( - arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional', - 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES', - )} - {renderSubStatusWithEnvVar( - // eslint-disable-next-line max-len - arePhotoCategoryOgImagesStaticallyOptimized ? 'checked' : 'optional', - 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES', - )} -
-
- - Set environment variable to {'"1"'} to prevent - image uploads being compressed before storing: - {renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])} - - - Set environment variable from {'"1-100"'} - {' '} - to control the quality of large photos - ({'"100"'} represents highest quality/largest size): - {renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])} - - - Set environment variable to {'"1"'} to prevent - image blur data being stored and displayed: - {renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])} - -
- } - optional - > - -
- {categoryVisibility.map((category, index) => - - {renderSubStatus( - 'checked', - <> - {index + 1} - {'.'} - {category} - , - )} - )} - {getHiddenCategories(categoryVisibility) - .map(category => - - {renderSubStatus( - 'optional', - - {'* '} - {category} - , - )} - )} -
- Configure order and visibility of categories - (seen in grid sidebar and CMD-K results) - by storing comma-separated values - (default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}): - {renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])} -
- -
-
- Set environment variable to {'"1"'} to prevent images - displaying when hovering over category links: - {renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])} -
-
-
- - Set environment variable to {'"1"'} to always show - expanded category content - {renderEnvVars(['NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES'])} - - - Set environment variable to {'"1"'} to only show tags - with 2 or more photos - {renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])} - -
- } - optional - > - -
- {SORT_BY_OPTIONS.map(({sortBy, string }) => - - {renderSubStatus( - sortBy === defaultSortBy ? 'checked' : 'optional', - `${string}${sortBy === APP_DEFAULT_SORT_BY - ? ' (default)' - : ''}`, - )} - )} -
- Change default sort on grid/full homepages - {renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])} -
- - Set environment variable to {'"1"'} to take priority field - into account when sorting photos (enabling may have - performance consequences): - {renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])} - - - Set environment variable to {'"1"'} to - show sort control in desktop nav on grid/full homepages: - {renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])} - -
- } - optional - > - - Set environment variable to {'"1"'} to hide keyboard shortcut - tooltips in areas like the main nav, and previous/next photo links: - {renderEnvVars(['NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS'])} - - - Set environment variable to {'"1"'} to hide EXIF data: - {renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])} - - - Set environment variable to {'"1"'} to hide - fullscreen photo zoom controls: - {renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])} - - - Set environment variable to {'"1"'} to hide - taken at time from photo meta: - {renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])} - - - Set environment variable to {'"1"'} to hide - {' '} - X (formerly Twitter) button from share modal: - {renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])} - - - Set environment variable to {'"1"'} to hide footer link: - {renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])} - - - } - optional - > - - Set environment variable to {'"1"'} to show grid layout - on homepage: - {renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])} - - - Set environment variable to any number to enforce aspect ratio - {' '} - (default is {'"1"'}, i.e., square)—set to {'"0"'} to disable: - {renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])} - - - Set environment variable to {'"1"'} to ensure large thumbnails - on photo grid views (if not configured, density is based on - aspect ratio): - {renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])} - - - } - optional - > - - {'Set environment variable to \'light\' or \'dark\''} - {' '} - to configure initial theme - {' '} - (defaults to {'\'system\''}): - {renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])} - - - Set environment variable to {'"1"'} to constrain the size - {' '} - of each photo, and display a surrounding border: -
- -
-
- - Set environment variable hex values (e.g., #cccccc) - to override matte colors: -
- } - /> - } - /> -
-
-
- } - optional - > - - Set environment variable to {'"1"'} to disable - collection/display of location-based data: - {renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])} - - - Set environment variable to {'"1"'} to enable - public photo downloads for all visitors: - {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} - - - Set environment variable to {'"1"'} to enable feeds at - {' '} - {renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}: - {renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])} - - - Set environment variable to {'"BOTTOM"'} to - keep OG image text bottom aligned (default is {'"top"'}): - {renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])} - - - {areInternalToolsEnabled && - } - optional - > - - Set environment variable to {'"1"'} to temporarily enable - features like photo matting, baseline grid, etc.: - {renderEnvVars(['ADMIN_DEBUG_TOOLS'])} - - - Set environment variable to {'"1"'} to prevent - homepages from seeding infinite scroll on load: - {renderEnvVars(['ADMIN_DB_OPTIMIZE'])} - - - Set environment variable to {'"1"'} to enable - console output for all sql queries: - {renderEnvVars(['ADMIN_SQL_DEBUG'])} - - } - } -
-
- Changes to environment variables require a redeploy - or reboot of local dev server -
-
-
- ); -} diff --git a/src/admin/AdminInfoPage.tsx b/src/admin/AdminInfoPage.tsx index 0371ed28..c413cb11 100644 --- a/src/admin/AdminInfoPage.tsx +++ b/src/admin/AdminInfoPage.tsx @@ -1,14 +1,14 @@ import Container from '@/components/Container'; import AppGrid from '@/components/AppGrid'; -import { ReactNode } from 'react'; +import { ComponentProps } from 'react'; export default function AdminInfoPage({ children, -}: { - children: ReactNode -}) { + ...props +}: Omit, 'contentMain'>) { return ( {children} diff --git a/src/admin/AdminAppConfiguration.tsx b/src/admin/config/AdminAppConfiguration.tsx similarity index 100% rename from src/admin/AdminAppConfiguration.tsx rename to src/admin/config/AdminAppConfiguration.tsx index b241a2f1..319ce79b 100644 --- a/src/admin/AdminAppConfiguration.tsx +++ b/src/admin/config/AdminAppConfiguration.tsx @@ -1,7 +1,7 @@ import { Suspense } from 'react'; import { APP_CONFIGURATION } from '@/app/config'; -import AdminAppConfigurationServer from './AdminAppConfigurationServer'; import AdminAppConfigurationClient from './AdminAppConfigurationClient'; +import AdminAppConfigurationServer from './AdminAppConfigurationServer'; export default function AdminAppConfiguration({ simplifiedView, diff --git a/src/admin/config/AdminAppConfigurationClient.tsx b/src/admin/config/AdminAppConfigurationClient.tsx new file mode 100644 index 00000000..b82c0c94 --- /dev/null +++ b/src/admin/config/AdminAppConfigurationClient.tsx @@ -0,0 +1,881 @@ +'use client'; + +import { + ComponentProps, + Fragment, + JSX, + ReactNode, + useEffect, + useState, +} from 'react'; +import ChecklistRow from '@/components/ChecklistRow'; +import ChecklistGroup from '@/components/ChecklistGroup'; +import { AppConfiguration } from '@/app/config'; +import StatusIcon from '@/components/StatusIcon'; +import { labelForStorage } from '@/platforms/storage'; +import { testConnectionsAction } from '@/admin/actions'; +import ErrorNote from '@/components/ErrorNote'; +import SecretGenerator from '@/app/SecretGenerator'; +import EnvVar from '@/components/EnvVar'; +import AdminLink from '@/admin/AdminLink'; +import ScoreCardContainer from '@/components/ScoreCardContainer'; +import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category'; +import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai'; +import clsx from 'clsx/lite'; +import Link from 'next/link'; +import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths'; +import { APP_DEFAULT_SORT_BY, SORT_BY_OPTIONS } from '@/photo/db/sort'; +import { + AdminConfigSection, + ConfigSectionKey, + getAdminConfigSections, +} from '.'; + +export default function AdminAppConfigurationClient({ + // Storage + hasDatabase, + isPostgresSslEnabled, + hasVercelPostgres, + hasRedisStorage, + hasStorageProvider, + hasVercelBlobStorage, + hasCloudflareR2Storage, + hasAwsS3Storage, + hasMultipleStorageProviders, + currentStorage, + // Auth + hasAuthSecret, + hasAdminUser, + // Content + locale, + hasLocale, + domain, + hasDomain, + metaTitle, + isMetaTitleConfigured, + metaDescription, + isMetaDescriptionConfigured, + navTitle, + hasNavTitle, + navCaption, + hasNavCaption, + pageAbout, + hasPageAbout, + // AI + hasOpenaiBaseUrl, + isAiTextGenerationEnabled, + aiTextAutoGeneratedFields, + hasAiTextAutoGeneratedFields, + // Performance + isStaticallyOptimized, + arePhotosStaticallyOptimized, + arePhotoOGImagesStaticallyOptimized, + arePhotoCategoriesStaticallyOptimized, + arePhotoCategoryOgImagesStaticallyOptimized, + areOriginalUploadsPreserved, + hasImageQuality, + imageQuality, + isBlurEnabled, + // Categories + hasCategoryVisibility, + categoryVisibility, + showCategoryImageHover, + collapseSidebarCategories, + hideTagsWithOnePhoto, + // Sort + hasDefaultSortBy, + defaultSortBy, + isSortWithPriority, + showSortControl, + // Display + showKeyboardShortcutTooltips, + showExifInfo, + showZoomControls, + showTakenAtTimeHidden, + showSocial, + showRepoLink, + // Grid + isGridHomepageEnabled, + gridAspectRatio, + hasGridAspectRatio, + hasHighGridDensity, + hasGridDensityPreference, + // Design + hasDefaultTheme, + defaultTheme, + arePhotosMatted, + arePhotoMatteColorsConfigured, + matteColor, + matteColorDark, + // Settings + isGeoPrivacyEnabled, + arePublicDownloadsEnabled, + areSiteFeedsEnabled, + isOgTextBottomAligned, + // Internal + areInternalToolsEnabled, + areAdminDebugToolsEnabled, + isAdminDbOptimizeEnabled, + isAdminSqlDebugEnabled, + // Connection status + databaseError, + storageError, + redisError, + aiError, + // Component props + simplifiedView, + isAnalyzingConfiguration, +}: AppConfiguration & + Partial>> & { + simplifiedView?: boolean + isAnalyzingConfiguration?: boolean +}) { + const [hasScrolled, setHasScrolled] = useState(false); + + useEffect(() => { + const handleScroll = () => { setHasScrolled(true); }; + window.addEventListener('scroll', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, []); + + const renderContent = (content?: ReactNode) => content + ?
+ {content} +
+ : null; + + const renderEnvVars = (variables: string[]) => +
+ {variables.map(variable => + )} +
; + + const renderSubStatus = ( + type: ComponentProps['type'], + label: ReactNode, + iconClassName = 'translate-y-[3.5px]', + ) => +
+ + + {label} + +
; + + const renderSubStatusWithEnvVar = ( + type: ComponentProps['type'], + variable: string, + ) => + renderSubStatus( + type, + renderEnvVars([variable]), + 'translate-y-[7px]', + ); + + const renderError = ({ + connection, + message, + }: { + connection?: { provider: string, error: string } + message?: string + }) => + + {connection && <> + {connection.provider} connection error: {`"${connection.error}"`} + } + {message} + ; + + const renderLink = (href: string, children?: ReactNode) => + + {children || href} + ; + + const renderGroupContent = (key: ConfigSectionKey): JSX.Element => { + switch (key) { + case 'Storage': + return <> + + {databaseError && renderError({ + connection: { provider: 'Database', error: databaseError}, + })} + {hasVercelPostgres + ? renderSubStatus('checked', 'Vercel Postgres: connected') + : renderSubStatus('optional', <> + Vercel Postgres: + {' '} + + create store + + {' '} + and connect to project + )} + {hasDatabase && !hasVercelPostgres && + renderSubStatus('checked', <> + Postgres-compatible: connected + {' '} + (SSL {isPostgresSslEnabled ? 'enabled' : 'disabled'}) + )} + + + {storageError && renderError({ + connection: { provider: 'Storage', error: storageError}, + })} +
+ {hasVercelBlobStorage + ? renderSubStatus('checked', 'Vercel Blob: connected') + : renderSubStatus('optional', <> + {labelForStorage('vercel-blob')}: + {' '} + + create store + + {' '} + and connect to project + , + )} + {hasCloudflareR2Storage + ? renderSubStatus('checked', 'Cloudflare R2: connected') + : renderSubStatus('optional', <> + {labelForStorage('cloudflare-r2')}: + {' '} + + create/configure bucket + + )} + {hasAwsS3Storage + ? renderSubStatus('checked', 'AWS S3: connected') + : renderSubStatus('optional', <> + {labelForStorage('aws-s3')}: + {' '} + + create/configure bucket + + )} +
+
+ ; + case 'Authentication': + return <> + + Store auth secret in environment variable: + {!hasAuthSecret && +
+ +
} + {renderEnvVars(['AUTH_SECRET'])} +
+ + Store admin email/password + {' '} + in environment variables: + {renderEnvVars([ + 'ADMIN_EMAIL', + 'ADMIN_PASSWORD', + ])} + + ; + case 'Content': + return <> + + {renderContent(locale)} + Store in environment variable + (check README for + {' '} + + supported languages + + ): + {renderEnvVars(['NEXT_PUBLIC_LOCALE'])} + + + {renderContent(domain)} + Store in environment variable + (used in explicit share urls, seen in nav if no title is defined): + {renderEnvVars(['NEXT_PUBLIC_DOMAIN'])} + + + {renderContent(metaTitle)} + Store in environment variable + (seen in search results and browser tab): + {renderEnvVars(['NEXT_PUBLIC_META_TITLE'])} + + {!simplifiedView && <> + + {renderContent(metaDescription)} + Store in environment variable + (seen in search results): + {renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])} + + + {renderContent(navTitle)} + Store in environment variable (replaces domain in top-right nav): + {renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])} + + + {hasNavCaption && renderContent(navCaption)} + Store in environment variable (seen in top-right nav, under title): + {renderEnvVars(['NEXT_PUBLIC_NAV_CAPTION'])} + + + {hasPageAbout && renderContent(pageAbout)} + Store in environment variable (seen in sidebar): + {renderEnvVars(['NEXT_PUBLIC_PAGE_ABOUT'])} + + } + ; + case 'AI Text Generation': + return <> + + {aiError && renderError({ + connection: { provider: 'OpenAI', error: aiError}, + })} + Store your OpenAI secret key in order to enable AI-generated + text descriptions and optionally leverage an invisible field + called {'"Semantic Description"'} used to support CMD-K search + and improve accessibility: + {renderEnvVars(['OPENAI_SECRET_KEY'])} + + +
+ {hasAiTextAutoGeneratedFields && + AI_AUTO_GENERATED_FIELDS_ALL.map(field => + + {renderSubStatus( + aiTextAutoGeneratedFields.includes(field) + ? 'checked' + : 'optional', + field, + )} + )} +
+ Comma-separated fields to auto-generate when + uploading photos. Accepted values: title, caption, + tags, description, all, or none + {' '} + (default: {'"title,tags,semantic"'}): + {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} +
+ + {redisError && renderError({ + connection: { provider: 'Redis', error: redisError}, + })} + Create Upstash Redis store from storage tab + on Vercel dashboard and connect to this project + to enable rate limiting + + + Store base URL in environment variable to use + alternate OpenAI-compatible providers: + {renderEnvVars(['OPENAI_BASE_URL'])} + + ; + case 'Performance': + return <> + + Set environment variable to {'"1"'} to make site more responsive + by enabling static optimization + (i.e., rendering pages and images at build time): +
+ {renderSubStatusWithEnvVar( + arePhotosStaticallyOptimized ? 'checked' : 'optional', + 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS', + )} + {renderSubStatusWithEnvVar( + arePhotoOGImagesStaticallyOptimized ? 'checked' : 'optional', + 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES', + )} + {renderSubStatusWithEnvVar( + arePhotoCategoriesStaticallyOptimized ? 'checked' : 'optional', + 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES', + )} + {renderSubStatusWithEnvVar( + // eslint-disable-next-line max-len + arePhotoCategoryOgImagesStaticallyOptimized ? 'checked' : 'optional', + 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES', + )} +
+
+ + Set environment variable to {'"1"'} to prevent + image uploads being compressed before storing: + {renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])} + + + Set environment variable from {'"1-100"'} + {' '} + to control the quality of large photos + ({'"100"'} represents highest quality/largest size): + {renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])} + + + Set environment variable to {'"1"'} to prevent + image blur data being stored and displayed: + {renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])} + + ; + case 'Categories': + return <> + +
+ {categoryVisibility.map((category, index) => + + {renderSubStatus( + 'checked', + <> + {index + 1} + {'.'} + {category} + , + )} + )} + {getHiddenCategories(categoryVisibility) + .map(category => + + {renderSubStatus( + 'optional', + + {'* '} + {category} + , + )} + )} +
+ Configure order and visibility of categories + (seen in grid sidebar and CMD-K results) + by storing comma-separated values + (default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}): + {renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])} +
+ +
+
+ Set environment variable to {'"1"'} to prevent images + displaying when hovering over category links: + {renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])} +
+
+
+ + Set environment variable to {'"1"'} to always show + expanded category content + {renderEnvVars(['NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES'])} + + + Set environment variable to {'"1"'} to only show tags + with 2 or more photos + {renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])} + + ; + case 'Sorting': + return <> + +
+ {SORT_BY_OPTIONS.map(({sortBy, string }) => + + {renderSubStatus( + sortBy === defaultSortBy ? 'checked' : 'optional', + `${string}${sortBy === APP_DEFAULT_SORT_BY + ? ' (default)' + : ''}`, + )} + )} +
+ Change default sort on grid/full homepages + {renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])} +
+ + Set environment variable to {'"1"'} to take priority field + into account when sorting photos (enabling may have + performance consequences): + {renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])} + + + Set environment variable to {'"1"'} to + show sort control in desktop nav on grid/full homepages: + {renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])} + + ; + case 'Display': + return <> + + Set environment variable to {'"1"'} to hide keyboard shortcut + tooltips in areas like the main nav, and previous/next photo links: + {renderEnvVars(['NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS'])} + + + Set environment variable to {'"1"'} to hide EXIF data: + {renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])} + + + Set environment variable to {'"1"'} to hide + fullscreen photo zoom controls: + {renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])} + + + Set environment variable to {'"1"'} to hide + taken at time from photo meta: + {renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])} + + + Set environment variable to {'"1"'} to hide + {' '} + X (formerly Twitter) button from share modal: + {renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])} + + + Set environment variable to {'"1"'} to hide footer link: + {renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])} + + ; + case 'Grid': + return <> + + Set environment variable to {'"1"'} to show grid layout + on homepage: + {renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])} + + + Set environment variable to any number to enforce aspect ratio + {' '} + (default is {'"1"'}, i.e., square)—set to {'"0"'} to disable: + {renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])} + + + Set environment variable to {'"1"'} to ensure large thumbnails + on photo grid views (if not configured, density is based on + aspect ratio): + {renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])} + + ; + case 'Design': + return <> + + {'Set environment variable to \'light\' or \'dark\''} + {' '} + to configure initial theme + {' '} + (defaults to {'\'system\''}): + {renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])} + + + Set environment variable to {'"1"'} to constrain the size + {' '} + of each photo, and display a surrounding border: +
+ +
+
+ + Set environment variable hex values (e.g., #cccccc) + to override matte colors: +
+ } + /> + } + /> +
+
+ ; + case 'Settings': + return <> + + Set environment variable to {'"1"'} to disable + collection/display of location-based data: + {renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])} + + + Set environment variable to {'"1"'} to enable + public photo downloads for all visitors: + {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} + + + Set environment variable to {'"1"'} to enable feeds at + {' '} + {renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}: + {renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])} + + + Set environment variable to {'"BOTTOM"'} to + keep OG image text bottom aligned (default is {'"top"'}): + {renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])} + + ; + case 'Internal': + return <> + + Set environment variable to {'"1"'} to temporarily enable + features like photo matting, baseline grid, etc.: + {renderEnvVars(['ADMIN_DEBUG_TOOLS'])} + + + Set environment variable to {'"1"'} to prevent + homepages from seeding infinite scroll on load: + {renderEnvVars(['ADMIN_DB_OPTIMIZE'])} + + + Set environment variable to {'"1"'} to enable + console output for all sql queries: + {renderEnvVars(['ADMIN_SQL_DEBUG'])} + + ; + } + }; + + return ( + + {getAdminConfigSections(areInternalToolsEnabled, simplifiedView) + .map((section) => ( + + {renderGroupContent(section.title)} + + ))} +
+
+ Changes to environment variables require a redeploy + or reboot of local dev server +
+
+
+ ); +} diff --git a/src/admin/AdminAppConfigurationServer.tsx b/src/admin/config/AdminAppConfigurationServer.tsx similarity index 100% rename from src/admin/AdminAppConfigurationServer.tsx rename to src/admin/config/AdminAppConfigurationServer.tsx diff --git a/src/admin/config/AdminAppConfigurationSidebar.tsx b/src/admin/config/AdminAppConfigurationSidebar.tsx new file mode 100644 index 00000000..0202ca57 --- /dev/null +++ b/src/admin/config/AdminAppConfigurationSidebar.tsx @@ -0,0 +1,38 @@ +'use client'; + +import clsx from 'clsx/lite'; +import { getAdminConfigSections } from '.'; +import { parameterize } from '@/utility/string'; +import useHash from '@/utility/useHash'; + +export default function AdminAppConfigurationSidebar({ + simplifiedView, + areInternalToolsEnabled, +}: { + simplifiedView?: boolean + areInternalToolsEnabled: boolean +}) { + const hash = useHash(); + return ( +
+ {getAdminConfigSections(areInternalToolsEnabled, simplifiedView) + .map(({ title }) => ( + + {title} + + ))} +
+ ); +} diff --git a/src/admin/config/index.tsx b/src/admin/config/index.tsx new file mode 100644 index 00000000..ad58f703 --- /dev/null +++ b/src/admin/config/index.tsx @@ -0,0 +1,78 @@ +import IconSort from '@/components/icons/IconSort'; +import { BiData, BiHide, BiLockAlt, BiPencil } from 'react-icons/bi'; +import { CgDebug } from 'react-icons/cg'; +import { FaRegFolderClosed } from 'react-icons/fa6'; +import { HiOutlineCog, HiSparkles } from 'react-icons/hi'; +import { IoMdGrid } from 'react-icons/io'; +import { PiPaintBrushHousehold } from 'react-icons/pi'; +import { RiSpeedMiniLine } from 'react-icons/ri'; + +export interface AdminConfigSection { + title: string; + titleShort?: string; + required?: boolean; + icon: React.ReactNode; +} + +const ADMIN_CONFIG_SECTIONS = [ + { + title: 'Storage', + required: true, + icon: , + }, { + title: 'Authentication', + required: true, + icon: , + }, { + title: 'Content', + required: true, + icon: , + }, { + title: 'AI Text Generation', + titleShort: 'AI', + required: false, + icon: , + }, { + title: 'Performance', + required: false, + icon: , + }, { + title: 'Categories', + required: false, + icon: , + }, { + title: 'Sorting', + required: false, + icon: , + }, { + title: 'Display', + required: false, + icon: , + }, { + title: 'Grid', + required: false, + icon: , + }, { + title: 'Design', + required: false, + icon: , + }, { + title: 'Settings', + required: false, + icon: , + }, { + title: 'Internal', + required: false, + icon: , + }, +] as const satisfies AdminConfigSection[]; + +export type ConfigSectionKey = typeof ADMIN_CONFIG_SECTIONS[number]['title']; + +export const getAdminConfigSections = ( + areInternalToolsEnabled: boolean, + simplifiedView?: boolean, +) => ADMIN_CONFIG_SECTIONS.filter(({ title, required }) => + (!simplifiedView || required) && + (areInternalToolsEnabled || title !== 'Internal'), +); diff --git a/src/app/Nav.tsx b/src/app/Nav.tsx index 61baa030..ca019be1 100644 --- a/src/app/Nav.tsx +++ b/src/app/Nav.tsx @@ -46,7 +46,7 @@ export default function Nav({ classNameStickyContainer, classNameStickyNav, isNavVisible, - } = useStickyNav(ref); + } = useStickyNav(ref, !isPathAdmin(pathname)); const renderLink = ( text: string, @@ -112,7 +112,6 @@ export default function Nav({ : []} /> } - sideHiddenOnMobile /> ); }; diff --git a/src/components/AppGrid.tsx b/src/components/AppGrid.tsx index d6057ce4..411a0b06 100644 --- a/src/components/AppGrid.tsx +++ b/src/components/AppGrid.tsx @@ -19,7 +19,7 @@ export default function AppGrid({ contentMain, contentSide, sideFirstOnMobile, - sideHiddenOnMobile, + sideHiddenOnMobile = true, ...props }: { containerRef?: RefObject @@ -58,7 +58,7 @@ export default function AppGrid({ 'col-span-1 md:col-span-3', '3xl:max-w-[260px]', sideFirstOnMobile && 'order-1 md:order-none', - sideHiddenOnMobile && 'hidden md:block', + sideHiddenOnMobile && 'max-md:hidden', classNameSide, )}> {contentSide} diff --git a/src/components/ChecklistGroup.tsx b/src/components/ChecklistGroup.tsx index cbe08f1b..ae523043 100644 --- a/src/components/ChecklistGroup.tsx +++ b/src/components/ChecklistGroup.tsx @@ -1,10 +1,11 @@ -import { ReactNode } from 'react'; +import { ReactNode, useRef } from 'react'; import { clsx } from 'clsx/lite'; import ExperimentalBadge from './ExperimentalBadge'; import Badge from './Badge'; import ResponsiveText from './primitives/ResponsiveText'; import { parameterize } from '@/utility/string'; import ScoreCard from './ScoreCard'; +import useVisible from '@/utility/useVisible'; export default function ChecklistGroup({ title, @@ -12,6 +13,7 @@ export default function ChecklistGroup({ icon, optional, experimental, + updateHashOnVisible, children, }: { title: string @@ -19,21 +21,32 @@ export default function ChecklistGroup({ icon?: ReactNode optional?: boolean experimental?: boolean + updateHashOnVisible?: boolean children: ReactNode }) { + const ref = useRef(null); + const slug = parameterize(title); + useVisible({ ref, onVisible: () => { + if (updateHashOnVisible) { + window.history.replaceState(null, '', `#${slug}`); + } + } }); + return ( - {icon} + {icon} {title} diff --git a/src/components/ScoreCard.tsx b/src/components/ScoreCard.tsx index de9b2763..bea04d6b 100644 --- a/src/components/ScoreCard.tsx +++ b/src/components/ScoreCard.tsx @@ -14,7 +14,7 @@ export default function ScoreCard({
{title &&
diff --git a/src/components/ScoreCardContainer.tsx b/src/components/ScoreCardContainer.tsx index 0d3f7faa..1cbd1b30 100644 --- a/src/components/ScoreCardContainer.tsx +++ b/src/components/ScoreCardContainer.tsx @@ -8,7 +8,7 @@ export default function ScoreCardContainer({ }) { return
{children}
; diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index 2786c729..6919cbdf 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -171,7 +171,6 @@ export default function PhotoDetailPage({ ]} />
} contentSide={sidebar} - sideHiddenOnMobile /> ); } diff --git a/src/photo/PhotosEmptyState.tsx b/src/photo/PhotosEmptyState.tsx index 7eea2d4a..98675dfa 100644 --- a/src/photo/PhotosEmptyState.tsx +++ b/src/photo/PhotosEmptyState.tsx @@ -4,7 +4,7 @@ import { IS_SITE_READY, PRESERVE_ORIGINAL_UPLOADS, } from '@/app/config'; -import AdminAppConfiguration from '@/admin/AdminAppConfiguration'; +import AdminAppConfiguration from '@/admin/config/AdminAppConfiguration'; import { clsx } from 'clsx/lite'; import { HiOutlinePhotograph } from 'react-icons/hi'; import { revalidatePath } from 'next/cache'; diff --git a/src/utility/useHash.ts b/src/utility/useHash.ts new file mode 100644 index 00000000..4b1241b3 --- /dev/null +++ b/src/utility/useHash.ts @@ -0,0 +1,25 @@ +import { useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; + +export default function useHash() { + const [hash, setHash] = useState(''); + + const updateHash = useCallback(() => { + setHash(window.location.hash); + }, []); + + useEffect(() => { + window.addEventListener('hashchange', updateHash); + return () => { + window.removeEventListener('hashchange', updateHash); + }; + }, [updateHash]); + + // Needed to capture non-request-initiated hash changes + const params = useSearchParams(); + useEffect(() => { + updateHash(); + }, [params, updateHash]); + + return hash.replace('#', ''); +}