'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 { CATEGORY_KEYS, DEFAULT_CATEGORY_KEYS } from '@/category'; import { AI_AUTO_GENERATED_FIELDS_ALL, AI_AUTO_GENERATED_FIELDS_DEFAULT, } from '@/photo/ai'; import clsx from 'clsx/lite'; import Link from 'next/link'; import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/path'; import { APP_DEFAULT_SORT_BY, DEFAULT_SORT_BY_OPTIONS } from '@/photo/sort'; import { AdminConfigSection, ConfigSectionKey, getAdminConfigSections, } from '.'; import ColorDot from '@/photo/color/ColorDot'; import { Oklch } from '@/photo/color/client'; import { getOrderedKeyListStatus } from '@/utility/key'; import { DEFAULT_SOCIAL_KEYS, SOCIAL_KEYS } from '@/social'; import MaskedScroll from '@/components/MaskedScroll'; import { IoLink } from 'react-icons/io5'; export default function AdminAppConfigurationClient({ // Storage hasDatabase, isPostgresSslEnabled, hasRedisStorage, hasStorageProvider, hasVercelBlobStorage, hasCloudflareR2Storage, hasAwsS3Storage, hasMinioStorage, 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, showCategoriesOnMobile, showCategoryImageHover, collapseSidebarCategories, hideTagsWithOnePhoto, // Sort hasDefaultSortBy, defaultSortBy, hasNavSortControl, navSortControl, isColorSortEnabled, hasColorSortConfiguration, colorSortStartingHue, colorSortChromaCutoff, isSortWithPriority, // Display showKeyboardShortcutTooltips, showExifInfo, showZoomControls, showTakenAtTimeHidden, showRepoLink, // Grid isGridHomepageEnabled, gridAspectRatio, hasGridAspectRatio, hasHighGridDensity, hasGridDensityPreference, // Design hasDefaultTheme, defaultTheme, arePhotosMatted, arePhotoMatteColorsConfigured, matteColor, matteColorDark, // Settings isGeoPrivacyEnabled, arePublicDownloadsEnabled, hasSocialKeys, socialKeys, areSiteFeedsEnabled, isOgTextBottomAligned, // Scripts & Analytics hasPageScriptUrls, pageScriptUrls, // 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 renderOrderedKeyList = ( selectedKeys: string[], acceptedKeys: readonly string[], ) =>
{getOrderedKeyListStatus({ selectedKeys, acceptedKeys }) .map(({ label, selected }) => {renderSubStatus( selected ? 'checked' : 'optional', selected ? label : {label}, )} )}
; 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 renderColorDot = (color: Oklch | string, includeTooltip?: boolean) => ; const renderCommaSeparatedList = (items: string[]) => <> {'"'} {items.map((item, index) => {item}{index < items.length - 1 ? <>,​ : <>} )} {'"'} ; const renderGroupContent = (key: ConfigSectionKey): JSX.Element => { switch (key) { case 'Storage': return <> {databaseError && renderError({ connection: { provider: 'Database', error: databaseError}, })} {hasDatabase ? renderSubStatus( 'checked', // eslint-disable-next-line max-len `Postgres: connected${!isPostgresSslEnabled ? ' (SSL disabled)' : ''}`, ) : renderSubStatus('missing', <> Postgres: {' '} create database {' '} and connect to project )} {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 )} {hasMinioStorage ? renderSubStatus('checked', 'MinIO: connected') : renderSubStatus('optional', <> {labelForStorage('minio')}: {' '} setup MinIO server )}
; 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 Content 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: {renderCommaSeparatedList( AI_AUTO_GENERATED_FIELDS_DEFAULT, )}): {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 <> {renderOrderedKeyList(categoryVisibility, CATEGORY_KEYS)}
Configure order and visibility of categories (seen in grid sidebar and CMD-K results) by storing comma-separated values (default: {renderCommaSeparatedList(DEFAULT_CATEGORY_KEYS)}):
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
Set environment variable to {'"1"'} to prevent categories displaying on mobile grid view: {renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORIES_ON_MOBILE'])}
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 <>
{DEFAULT_SORT_BY_OPTIONS .map(({sortBy, configKey }) => {renderSubStatus( sortBy === defaultSortBy ? 'checked' : 'optional', `${configKey}${sortBy === APP_DEFAULT_SORT_BY ? ' (default)' : ''}`, )} )}
Change default sort on grid/full homepages {renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
Set environment variable to {'"none"'}, {'"toggle"'} (default), or {'"menu"'}, to control sort UI on grid/full homepages: {renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])} Set environment variable to {'"1"'} to enable color-based sorting (forces nav sort control to {'"menu,"'} flags photos missing color data in admin dashboard)—color identification benefits greatly from AI being enabled: {renderEnvVars([ 'NEXT_PUBLIC_COLOR_SORT', ])} Configure which colors start first (accepts a hue of 0 to 360, default: 80) and which are considered sufficiently vibrant (accepts a chroma of 0 to 0.37, default: 0.05):
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'])} ; 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 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'])} {renderOrderedKeyList(socialKeys, SOCIAL_KEYS)}
Configure order and visibility of social networks (seen in share modal) by storing comma-separated values (accepts {'"all"'} or {'"none"'}, defaults to {renderCommaSeparatedList(DEFAULT_SOCIAL_KEYS)})
{renderEnvVars(['NEXT_PUBLIC_SOCIAL_NETWORKS'])}
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 'Scripts & Analytics': return <> {pageScriptUrls.length > 0 &&
{pageScriptUrls.map(url => {url} )}
} Set environment variable to comma-separated list of URLs to be added to the bottom of the body tag via {'"next/script"'}: {renderEnvVars(['PAGE_SCRIPT_URLS'])}
; 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 new deployment to take effect
); }