Add section menu to admin app config

This commit is contained in:
Sam Becker 2025-07-13 18:38:33 -05:00
parent 188c704589
commit aefda2db19
17 changed files with 1062 additions and 913 deletions

View File

@ -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 AdminInfoPage from '@/admin/AdminInfoPage';
import { APP_CONFIGURATION } from '@/app/config';
import { Suspense } from 'react';
export default function AdminAppConfigurationPage() { export default function AdminAppConfigurationPage() {
const { areInternalToolsEnabled } = APP_CONFIGURATION;
return ( return (
<AdminInfoPage> <AdminInfoPage
// Necessary because of useSearchParams usage in sidebar anchors
contentSide={<Suspense>
<AdminAppConfigurationSidebar
{...{ areInternalToolsEnabled }}
/>
</Suspense>}
>
<AdminAppConfiguration /> <AdminAppConfiguration />
</AdminInfoPage> </AdminInfoPage>
); );

View File

@ -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<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean
isAnalyzingConfiguration?: boolean
}) {
const renderContent = (content?: ReactNode) => content
? <div className={clsx(
'my-1 px-2 py-1',
'bg-dim rounded-lg',
)}>
{content}
</div>
: null;
const renderEnvVars = (variables: string[]) =>
<div className="pt-1 flex flex-col gap-0.5">
{variables.map(variable =>
<EnvVar key={variable} variable={variable} />)}
</div>;
const renderSubStatus = (
type: ComponentProps<typeof StatusIcon>['type'],
label: ReactNode,
iconClassName = 'translate-y-[3.5px]',
) =>
<div className="flex gap-2 translate-x-[-2.5px]">
<StatusIcon {...{ type, className: iconClassName }} />
<span className="min-w-0">
{label}
</span>
</div>;
const renderSubStatusWithEnvVar = (
type: ComponentProps<typeof StatusIcon>['type'],
variable: string,
) =>
renderSubStatus(
type,
renderEnvVars([variable]),
'translate-y-[7px]',
);
const renderError = ({
connection,
message,
}: {
connection?: { provider: string, error: string }
message?: string
}) =>
<ErrorNote className="mt-2 mb-3">
{connection && <>
{connection.provider} connection error: {`"${connection.error}"`}
</>}
{message}
</ErrorNote>;
const renderLink = (href: string, children?: ReactNode) =>
<Link
href={href}
className="underline underline-offset-3 hover:no-underline"
target="_blank"
>
{children || href}
</Link>;
return (
<ScoreCardContainer>
<ChecklistGroup
title="Storage"
icon={<BiData size={16} />}
>
<ChecklistRow
title={hasDatabase && isAnalyzingConfiguration
? 'Testing database connection'
: 'Setup database'}
status={hasDatabase}
isPending={hasDatabase && isAnalyzingConfiguration}
>
{databaseError && renderError({
connection: { provider: 'Database', error: databaseError},
})}
{hasVercelPostgres
? renderSubStatus('checked', 'Vercel Postgres: connected')
: renderSubStatus('optional', <>
Vercel Postgres:
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://vercel.com/docs/storage/vercel-postgres/quickstart#create-a-postgres-database"
externalIcon
>
create store
</AdminLink>
{' '}
and connect to project
</>)}
{hasDatabase && !hasVercelPostgres &&
renderSubStatus('checked', <>
Postgres-compatible: connected
{' '}
(SSL {isPostgresSslEnabled ? 'enabled' : 'disabled'})
</>)}
</ChecklistRow>
<ChecklistRow
title={
hasStorageProvider && isAnalyzingConfiguration
? 'Testing storage connection'
: !hasStorageProvider
? 'Setup storage (one of the following)'
: hasMultipleStorageProviders
// eslint-disable-next-line max-len
? `Setup storage (new uploads go to: ${labelForStorage(currentStorage)})`
: 'Setup storage'}
status={hasStorageProvider}
isPending={hasStorageProvider && isAnalyzingConfiguration}
>
{storageError && renderError({
connection: { provider: 'Storage', error: storageError},
})}
<div>
{hasVercelBlobStorage
? renderSubStatus('checked', 'Vercel Blob: connected')
: renderSubStatus('optional', <>
{labelForStorage('vercel-blob')}:
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store"
externalIcon
>
create store
</AdminLink>
{' '}
and connect to project
</>,
)}
{hasCloudflareR2Storage
? renderSubStatus('checked', 'Cloudflare R2: connected')
: renderSubStatus('optional', <>
{labelForStorage('cloudflare-r2')}:
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2"
externalIcon
>
create/configure bucket
</AdminLink>
</>)}
{hasAwsS3Storage
? renderSubStatus('checked', 'AWS S3: connected')
: renderSubStatus('optional', <>
{labelForStorage('aws-s3')}:
{' '}
<AdminLink
href="https://github.com/sambecker/exif-photo-blog#aws-s3"
externalIcon
>
create/configure bucket
</AdminLink>
</>)}
</div>
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Authentication"
icon={<BiLockAlt size={16} />}
>
<ChecklistRow
title={!hasAuthSecret && isAnalyzingConfiguration
? 'Generating secret'
: 'Setup auth'}
status={hasAuthSecret}
isPending={!hasAuthSecret && isAnalyzingConfiguration}
>
Store auth secret in environment variable:
{!hasAuthSecret &&
<div className="overflow-x-auto">
<SecretGenerator />
</div>}
{renderEnvVars(['AUTH_SECRET'])}
</ChecklistRow>
<ChecklistRow
title="Setup admin user"
status={hasAdminUser}
>
Store admin email/password
{' '}
in environment variables:
{renderEnvVars([
'ADMIN_EMAIL',
'ADMIN_PASSWORD',
])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Content"
icon={<BiPencil size={16} />}
>
<ChecklistRow
title="Configure language"
status={hasLocale}
optional
>
{renderContent(locale)}
Store in environment variable
(check README for
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://github.com/sambecker/exif-photo-blog?tab=readme-ov-file#supported-languages"
>
supported languages
</AdminLink>
):
{renderEnvVars(['NEXT_PUBLIC_LOCALE'])}
</ChecklistRow>
<ChecklistRow
title="Configure domain"
status={hasDomain}
>
{renderContent(domain)}
Store in environment variable
(used in explicit share urls, seen in nav if no title is defined):
{renderEnvVars(['NEXT_PUBLIC_DOMAIN'])}
</ChecklistRow>
<ChecklistRow
title="Meta title"
status={isMetaTitleConfigured}
showWarning
>
{renderContent(metaTitle)}
Store in environment variable
(seen in search results and browser tab):
{renderEnvVars(['NEXT_PUBLIC_META_TITLE'])}
</ChecklistRow>
{!simplifiedView && <>
<ChecklistRow
title="Meta description"
status={isMetaDescriptionConfigured}
optional
>
{renderContent(metaDescription)}
Store in environment variable
(seen in search results):
{renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])}
</ChecklistRow>
<ChecklistRow
title="Nav title"
status={hasNavTitle}
optional
>
{renderContent(navTitle)}
Store in environment variable (replaces domain in top-right nav):
{renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])}
</ChecklistRow>
<ChecklistRow
title="Nav caption"
status={hasNavCaption}
optional
>
{hasNavCaption && renderContent(navCaption)}
Store in environment variable (seen in top-right nav, under title):
{renderEnvVars(['NEXT_PUBLIC_NAV_CAPTION'])}
</ChecklistRow>
<ChecklistRow
title="Page about"
status={hasPageAbout}
optional
>
{hasPageAbout && renderContent(pageAbout)}
Store in environment variable (seen in sidebar):
{renderEnvVars(['NEXT_PUBLIC_PAGE_ABOUT'])}
</ChecklistRow>
</>}
</ChecklistGroup>
{!simplifiedView && <>
<ChecklistGroup
title="AI text generation"
titleShort="AI"
icon={<HiSparkles size={14} />}
optional
>
<ChecklistRow
title={isAiTextGenerationEnabled && isAnalyzingConfiguration
? 'Testing OpenAI connection'
: 'Add OpenAI secret key'}
status={isAiTextGenerationEnabled}
isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration}
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'])}
</ChecklistRow>
<ChecklistRow
title={'Auto-generated fields'}
status={hasAiTextAutoGeneratedFields}
optional
>
<div>
{hasAiTextAutoGeneratedFields &&
AI_AUTO_GENERATED_FIELDS_ALL.map(field =>
<Fragment key={field}>
{renderSubStatus(
aiTextAutoGeneratedFields.includes(field)
? 'checked'
: 'optional',
field,
)}
</Fragment>)}
</div>
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'])}
</ChecklistRow>
<ChecklistRow
title={hasRedisStorage && isAnalyzingConfiguration
? 'Testing Redis connection'
: 'Enable rate limiting'}
status={hasRedisStorage}
isPending={hasRedisStorage && isAnalyzingConfiguration}
optional
>
{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
</ChecklistRow>
<ChecklistRow
title="Base URL override (experimental)"
status={hasOpenaiBaseUrl}
optional
>
Store base URL in environment variable to use
alternate OpenAI-compatible providers:
{renderEnvVars(['OPENAI_BASE_URL'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Performance"
icon={<RiSpeedMiniLine size={19} />}
optional
>
<ChecklistRow
title="Static optimization"
status={isStaticallyOptimized}
optional
>
Set environment variable to {'"1"'} to make site more responsive
by enabling static optimization
(i.e., rendering pages and images at build time):
<div>
{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',
)}
</div>
</ChecklistRow>
<ChecklistRow
title="Preserve original uploads"
status={areOriginalUploadsPreserved}
optional
>
Set environment variable to {'"1"'} to prevent
image uploads being compressed before storing:
{renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])}
</ChecklistRow>
<ChecklistRow
title={`Image quality: ${imageQuality}`}
status={hasImageQuality}
optional
>
Set environment variable from {'"1-100"'}
{' '}
to control the quality of large photos
({'"100"'} represents highest quality/largest size):
{renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])}
</ChecklistRow>
<ChecklistRow
title="Image blur"
status={isBlurEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed:
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Categories"
icon={<FaRegFolderClosed size={15} />}
optional
>
<ChecklistRow
title="Visibility and ordering"
status={hasCategoryVisibility}
optional
>
<div>
{categoryVisibility.map((category, index) =>
<Fragment key={category}>
{renderSubStatus(
'checked',
<>
{index + 1}
{'.'}
{category}
</>,
)}
</Fragment>)}
{getHiddenCategories(categoryVisibility)
.map(category =>
<Fragment key={category}>
{renderSubStatus(
'optional',
<span className="text-dim">
{'* '}
{category}
</span>,
)}
</Fragment>)}
</div>
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'])}
</ChecklistRow>
<ChecklistRow
title="Show image hovers"
status={showCategoryImageHover}
optional
>
<div className="flex flex-col gap-2">
<div>
Set environment variable to {'"1"'} to prevent images
displaying when hovering over category links:
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])}
</div>
</div>
</ChecklistRow>
<ChecklistRow
title="Collapsible sidebar"
status={collapseSidebarCategories}
optional
>
Set environment variable to {'"1"'} to always show
expanded category content
{renderEnvVars(['NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES'])}
</ChecklistRow>
<ChecklistRow
title="Hide tags with only 1 photo"
status={hideTagsWithOnePhoto}
optional
>
Set environment variable to {'"1"'} to only show tags
with 2 or more photos
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Sorting"
icon={<IconSort size={18} className="translate-y-[1px]" />}
optional
>
<ChecklistRow
title="Order"
status={hasDefaultSortBy}
optional
>
<div>
{SORT_BY_OPTIONS.map(({sortBy, string }) =>
<Fragment key={ sortBy }>
{renderSubStatus(
sortBy === defaultSortBy ? 'checked' : 'optional',
`${string}${sortBy === APP_DEFAULT_SORT_BY
? ' (default)'
: ''}`,
)}
</Fragment>)}
</div>
Change default sort on grid/full homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
</ChecklistRow>
<ChecklistRow
title="Priority-based"
status={isSortWithPriority}
optional
>
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'])}
</ChecklistRow>
<ChecklistRow
title="Show nav button"
status={showSortControl}
optional
>
Set environment variable to {'"1"'} to
show sort control in desktop nav on grid/full homepages:
{renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Display"
icon={<BiHide size={18} />}
optional
>
<ChecklistRow
title="Show keyboard shortcut tooltips"
status={showKeyboardShortcutTooltips}
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'])}
</ChecklistRow>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title="Show zoom controls"
status={showZoomControls}
optional
>
Set environment variable to {'"1"'} to hide
fullscreen photo zoom controls:
{renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])}
</ChecklistRow>
<ChecklistRow
title="Show taken at time"
status={showTakenAtTimeHidden}
optional
>
Set environment variable to {'"1"'} to hide
taken at time from photo meta:
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])}
</ChecklistRow>
<ChecklistRow
title="Show social"
status={showSocial}
optional
>
Set environment variable to {'"1"'} to hide
{' '}
X (formerly Twitter) button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Grid"
icon={<IoMdGrid size={17} />}
optional
>
<ChecklistRow
title="Grid homepage"
status={isGridHomepageEnabled}
optional
>
Set environment variable to {'"1"'} to show grid layout
on homepage:
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
</ChecklistRow>
<ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
optional
>
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'])}
</ChecklistRow>
<ChecklistRow
title={`Grid density: ${hasHighGridDensity ? 'high' : 'low'}`}
status={hasGridDensityPreference}
optional
>
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'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Design"
icon={<PiPaintBrushHousehold size={19} />}
optional
>
<ChecklistRow
title={`Default theme: ${defaultTheme}`}
status={hasDefaultTheme}
optional
>
{'Set environment variable to \'light\' or \'dark\''}
{' '}
to configure initial theme
{' '}
(defaults to {'\'system\''}):
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
</ChecklistRow>
<ChecklistRow
title="Photo matting"
status={arePhotosMatted}
optional
>
Set environment variable to {'"1"'} to constrain the size
{' '}
of each photo, and display a surrounding border:
<div className="pt-1 flex flex-col gap-1">
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" />
</div>
</ChecklistRow>
<ChecklistRow
title="Custom photo matting colors"
status={arePhotoMatteColorsConfigured}
optional
>
Set environment variable hex values (e.g., #cccccc)
to override matte colors:
<div className="pt-1 flex flex-col gap-1">
<EnvVar
variable="NEXT_PUBLIC_MATTE_COLOR"
accessory={matteColor && <span
className="size-[15px] border-medium rounded-sm ml-1"
style={{ backgroundColor: matteColor }}
/>}
/>
<EnvVar
variable="NEXT_PUBLIC_MATTE_COLOR_DARK"
accessory={matteColorDark && <span
className="size-[15px] border-medium rounded-sm ml-1"
style={{ backgroundColor: matteColorDark }}
/>}
/>
</div>
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Settings"
icon={<HiOutlineCog size={17} className="translate-y-[0.5px]" />}
optional
>
<ChecklistRow
title="Geo privacy"
status={isGeoPrivacyEnabled}
optional
>
Set environment variable to {'"1"'} to disable
collection/display of location-based data:
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Public downloads"
status={arePublicDownloadsEnabled}
optional
>
Set environment variable to {'"1"'} to enable
public photo downloads for all visitors:
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
</ChecklistRow>
<ChecklistRow
title="Site feeds (JSON/RSS)"
status={areSiteFeedsEnabled}
optional
>
Set environment variable to {'"1"'} to enable feeds at
{' '}
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
</ChecklistRow>
<ChecklistRow
title="Legacy OG text alignment"
status={isOgTextBottomAligned}
optional
>
Set environment variable to {'"BOTTOM"'} to
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</ChecklistGroup>
{areInternalToolsEnabled &&
<ChecklistGroup
title="Internal"
icon={<CgDebug size={16} />}
optional
>
<ChecklistRow
title="Debug tools"
status={areAdminDebugToolsEnabled}
optional
>
Set environment variable to {'"1"'} to temporarily enable
features like photo matting, baseline grid, etc.:
{renderEnvVars(['ADMIN_DEBUG_TOOLS'])}
</ChecklistRow>
<ChecklistRow
title="DB optimize"
status={isAdminDbOptimizeEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
homepages from seeding infinite scroll on load:
{renderEnvVars(['ADMIN_DB_OPTIMIZE'])}
</ChecklistRow>
<ChecklistRow
title="SQL debugging"
status={isAdminSqlDebugEnabled}
optional
>
Set environment variable to {'"1"'} to enable
console output for all sql queries:
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
</ChecklistRow>
</ChecklistGroup>}
</>}
<div className="pl-11 pr-2 sm:pr-11 mt-4 md:mt-7">
<div>
Changes to environment variables require a redeploy
or reboot of local dev server
</div>
</div>
</ScoreCardContainer>
);
}

View File

@ -1,14 +1,14 @@
import Container from '@/components/Container'; import Container from '@/components/Container';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import { ReactNode } from 'react'; import { ComponentProps } from 'react';
export default function AdminInfoPage({ export default function AdminInfoPage({
children, children,
}: { ...props
children: ReactNode }: Omit<ComponentProps<typeof AppGrid>, 'contentMain'>) {
}) {
return ( return (
<AppGrid <AppGrid
{...props}
contentMain={ contentMain={
<Container spaceChildren={false}> <Container spaceChildren={false}>
{children} {children}

View File

@ -1,7 +1,7 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { APP_CONFIGURATION } from '@/app/config'; import { APP_CONFIGURATION } from '@/app/config';
import AdminAppConfigurationServer from './AdminAppConfigurationServer';
import AdminAppConfigurationClient from './AdminAppConfigurationClient'; import AdminAppConfigurationClient from './AdminAppConfigurationClient';
import AdminAppConfigurationServer from './AdminAppConfigurationServer';
export default function AdminAppConfiguration({ export default function AdminAppConfiguration({
simplifiedView, simplifiedView,

View File

@ -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<Awaited<ReturnType<typeof testConnectionsAction>>> & {
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
? <div className={clsx(
'my-1 px-2 py-1',
'bg-dim rounded-lg',
)}>
{content}
</div>
: null;
const renderEnvVars = (variables: string[]) =>
<div className="pt-1 flex flex-col gap-0.5">
{variables.map(variable =>
<EnvVar key={variable} variable={variable} />)}
</div>;
const renderSubStatus = (
type: ComponentProps<typeof StatusIcon>['type'],
label: ReactNode,
iconClassName = 'translate-y-[3.5px]',
) =>
<div className="flex gap-2 translate-x-[-2.5px]">
<StatusIcon {...{ type, className: iconClassName }} />
<span className="min-w-0">
{label}
</span>
</div>;
const renderSubStatusWithEnvVar = (
type: ComponentProps<typeof StatusIcon>['type'],
variable: string,
) =>
renderSubStatus(
type,
renderEnvVars([variable]),
'translate-y-[7px]',
);
const renderError = ({
connection,
message,
}: {
connection?: { provider: string, error: string }
message?: string
}) =>
<ErrorNote className="mt-2 mb-3">
{connection && <>
{connection.provider} connection error: {`"${connection.error}"`}
</>}
{message}
</ErrorNote>;
const renderLink = (href: string, children?: ReactNode) =>
<Link
href={href}
className="underline underline-offset-3 hover:no-underline"
target="_blank"
>
{children || href}
</Link>;
const renderGroupContent = (key: ConfigSectionKey): JSX.Element => {
switch (key) {
case 'Storage':
return <>
<ChecklistRow
title={hasDatabase && isAnalyzingConfiguration
? 'Testing database connection'
: 'Setup database'}
status={hasDatabase}
isPending={hasDatabase && isAnalyzingConfiguration}
>
{databaseError && renderError({
connection: { provider: 'Database', error: databaseError},
})}
{hasVercelPostgres
? renderSubStatus('checked', 'Vercel Postgres: connected')
: renderSubStatus('optional', <>
Vercel Postgres:
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://vercel.com/docs/storage/vercel-postgres/quickstart#create-a-postgres-database"
externalIcon
>
create store
</AdminLink>
{' '}
and connect to project
</>)}
{hasDatabase && !hasVercelPostgres &&
renderSubStatus('checked', <>
Postgres-compatible: connected
{' '}
(SSL {isPostgresSslEnabled ? 'enabled' : 'disabled'})
</>)}
</ChecklistRow>
<ChecklistRow
title={
hasStorageProvider && isAnalyzingConfiguration
? 'Testing storage connection'
: !hasStorageProvider
? 'Setup storage (one of the following)'
: hasMultipleStorageProviders
// eslint-disable-next-line max-len
? `Setup storage (new uploads go to: ${labelForStorage(currentStorage)})`
: 'Setup storage'}
status={hasStorageProvider}
isPending={hasStorageProvider && isAnalyzingConfiguration}
>
{storageError && renderError({
connection: { provider: 'Storage', error: storageError},
})}
<div>
{hasVercelBlobStorage
? renderSubStatus('checked', 'Vercel Blob: connected')
: renderSubStatus('optional', <>
{labelForStorage('vercel-blob')}:
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store"
externalIcon
>
create store
</AdminLink>
{' '}
and connect to project
</>,
)}
{hasCloudflareR2Storage
? renderSubStatus('checked', 'Cloudflare R2: connected')
: renderSubStatus('optional', <>
{labelForStorage('cloudflare-r2')}:
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://github.com/sambecker/exif-photo-blog#cloudflare-r2"
externalIcon
>
create/configure bucket
</AdminLink>
</>)}
{hasAwsS3Storage
? renderSubStatus('checked', 'AWS S3: connected')
: renderSubStatus('optional', <>
{labelForStorage('aws-s3')}:
{' '}
<AdminLink
href="https://github.com/sambecker/exif-photo-blog#aws-s3"
externalIcon
>
create/configure bucket
</AdminLink>
</>)}
</div>
</ChecklistRow>
</>;
case 'Authentication':
return <>
<ChecklistRow
title={!hasAuthSecret && isAnalyzingConfiguration
? 'Generating secret'
: 'Setup auth'}
status={hasAuthSecret}
isPending={!hasAuthSecret && isAnalyzingConfiguration}
>
Store auth secret in environment variable:
{!hasAuthSecret &&
<div className="overflow-x-auto">
<SecretGenerator />
</div>}
{renderEnvVars(['AUTH_SECRET'])}
</ChecklistRow>
<ChecklistRow
title="Setup admin user"
status={hasAdminUser}
>
Store admin email/password
{' '}
in environment variables:
{renderEnvVars([
'ADMIN_EMAIL',
'ADMIN_PASSWORD',
])}
</ChecklistRow>
</>;
case 'Content':
return <>
<ChecklistRow
title="Configure language"
status={hasLocale}
optional
>
{renderContent(locale)}
Store in environment variable
(check README for
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://github.com/sambecker/exif-photo-blog?tab=readme-ov-file#supported-languages"
>
supported languages
</AdminLink>
):
{renderEnvVars(['NEXT_PUBLIC_LOCALE'])}
</ChecklistRow>
<ChecklistRow
title="Configure domain"
status={hasDomain}
>
{renderContent(domain)}
Store in environment variable
(used in explicit share urls, seen in nav if no title is defined):
{renderEnvVars(['NEXT_PUBLIC_DOMAIN'])}
</ChecklistRow>
<ChecklistRow
title="Meta title"
status={isMetaTitleConfigured}
showWarning
>
{renderContent(metaTitle)}
Store in environment variable
(seen in search results and browser tab):
{renderEnvVars(['NEXT_PUBLIC_META_TITLE'])}
</ChecklistRow>
{!simplifiedView && <>
<ChecklistRow
title="Meta description"
status={isMetaDescriptionConfigured}
optional
>
{renderContent(metaDescription)}
Store in environment variable
(seen in search results):
{renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])}
</ChecklistRow>
<ChecklistRow
title="Nav title"
status={hasNavTitle}
optional
>
{renderContent(navTitle)}
Store in environment variable (replaces domain in top-right nav):
{renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])}
</ChecklistRow>
<ChecklistRow
title="Nav caption"
status={hasNavCaption}
optional
>
{hasNavCaption && renderContent(navCaption)}
Store in environment variable (seen in top-right nav, under title):
{renderEnvVars(['NEXT_PUBLIC_NAV_CAPTION'])}
</ChecklistRow>
<ChecklistRow
title="Page about"
status={hasPageAbout}
optional
>
{hasPageAbout && renderContent(pageAbout)}
Store in environment variable (seen in sidebar):
{renderEnvVars(['NEXT_PUBLIC_PAGE_ABOUT'])}
</ChecklistRow>
</>}
</>;
case 'AI Text Generation':
return <>
<ChecklistRow
title={isAiTextGenerationEnabled && isAnalyzingConfiguration
? 'Testing OpenAI connection'
: 'Add OpenAI secret key'}
status={isAiTextGenerationEnabled}
isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration}
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'])}
</ChecklistRow>
<ChecklistRow
title={'Auto-generated fields'}
status={hasAiTextAutoGeneratedFields}
optional
>
<div>
{hasAiTextAutoGeneratedFields &&
AI_AUTO_GENERATED_FIELDS_ALL.map(field =>
<Fragment key={field}>
{renderSubStatus(
aiTextAutoGeneratedFields.includes(field)
? 'checked'
: 'optional',
field,
)}
</Fragment>)}
</div>
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'])}
</ChecklistRow>
<ChecklistRow
title={hasRedisStorage && isAnalyzingConfiguration
? 'Testing Redis connection'
: 'Enable rate limiting'}
status={hasRedisStorage}
isPending={hasRedisStorage && isAnalyzingConfiguration}
optional
>
{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
</ChecklistRow>
<ChecklistRow
title="Base URL override (experimental)"
status={hasOpenaiBaseUrl}
optional
>
Store base URL in environment variable to use
alternate OpenAI-compatible providers:
{renderEnvVars(['OPENAI_BASE_URL'])}
</ChecklistRow>
</>;
case 'Performance':
return <>
<ChecklistRow
title="Static optimization"
status={isStaticallyOptimized}
optional
>
Set environment variable to {'"1"'} to make site more responsive
by enabling static optimization
(i.e., rendering pages and images at build time):
<div>
{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',
)}
</div>
</ChecklistRow>
<ChecklistRow
title="Preserve original uploads"
status={areOriginalUploadsPreserved}
optional
>
Set environment variable to {'"1"'} to prevent
image uploads being compressed before storing:
{renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])}
</ChecklistRow>
<ChecklistRow
title={`Image quality: ${imageQuality}`}
status={hasImageQuality}
optional
>
Set environment variable from {'"1-100"'}
{' '}
to control the quality of large photos
({'"100"'} represents highest quality/largest size):
{renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])}
</ChecklistRow>
<ChecklistRow
title="Image blur"
status={isBlurEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed:
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
</>;
case 'Categories':
return <>
<ChecklistRow
title="Visibility and ordering"
status={hasCategoryVisibility}
optional
>
<div>
{categoryVisibility.map((category, index) =>
<Fragment key={category}>
{renderSubStatus(
'checked',
<>
{index + 1}
{'.'}
{category}
</>,
)}
</Fragment>)}
{getHiddenCategories(categoryVisibility)
.map(category =>
<Fragment key={category}>
{renderSubStatus(
'optional',
<span className="text-dim">
{'* '}
{category}
</span>,
)}
</Fragment>)}
</div>
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'])}
</ChecklistRow>
<ChecklistRow
title="Show image hovers"
status={showCategoryImageHover}
optional
>
<div className="flex flex-col gap-2">
<div>
Set environment variable to {'"1"'} to prevent images
displaying when hovering over category links:
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])}
</div>
</div>
</ChecklistRow>
<ChecklistRow
title="Collapsible sidebar"
status={collapseSidebarCategories}
optional
>
Set environment variable to {'"1"'} to always show
expanded category content
{renderEnvVars(['NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES'])}
</ChecklistRow>
<ChecklistRow
title="Hide tags with only 1 photo"
status={hideTagsWithOnePhoto}
optional
>
Set environment variable to {'"1"'} to only show tags
with 2 or more photos
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO'])}
</ChecklistRow>
</>;
case 'Sorting':
return <>
<ChecklistRow
title="Order"
status={hasDefaultSortBy}
optional
>
<div>
{SORT_BY_OPTIONS.map(({sortBy, string }) =>
<Fragment key={ sortBy }>
{renderSubStatus(
sortBy === defaultSortBy ? 'checked' : 'optional',
`${string}${sortBy === APP_DEFAULT_SORT_BY
? ' (default)'
: ''}`,
)}
</Fragment>)}
</div>
Change default sort on grid/full homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
</ChecklistRow>
<ChecklistRow
title="Priority-based"
status={isSortWithPriority}
optional
>
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'])}
</ChecklistRow>
<ChecklistRow
title="Show nav button"
status={showSortControl}
optional
>
Set environment variable to {'"1"'} to
show sort control in desktop nav on grid/full homepages:
{renderEnvVars(['NEXT_PUBLIC_SHOW_SORT_CONTROL'])}
</ChecklistRow>
</>;
case 'Display':
return <>
<ChecklistRow
title="Show keyboard shortcut tooltips"
status={showKeyboardShortcutTooltips}
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'])}
</ChecklistRow>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title="Show zoom controls"
status={showZoomControls}
optional
>
Set environment variable to {'"1"'} to hide
fullscreen photo zoom controls:
{renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])}
</ChecklistRow>
<ChecklistRow
title="Show taken at time"
status={showTakenAtTimeHidden}
optional
>
Set environment variable to {'"1"'} to hide
taken at time from photo meta:
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])}
</ChecklistRow>
<ChecklistRow
title="Show social"
status={showSocial}
optional
>
Set environment variable to {'"1"'} to hide
{' '}
X (formerly Twitter) button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
</>;
case 'Grid':
return <>
<ChecklistRow
title="Grid homepage"
status={isGridHomepageEnabled}
optional
>
Set environment variable to {'"1"'} to show grid layout
on homepage:
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
</ChecklistRow>
<ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
optional
>
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'])}
</ChecklistRow>
<ChecklistRow
title={`Grid density: ${hasHighGridDensity ? 'high' : 'low'}`}
status={hasGridDensityPreference}
optional
>
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'])}
</ChecklistRow>
</>;
case 'Design':
return <>
<ChecklistRow
title={`Default theme: ${defaultTheme}`}
status={hasDefaultTheme}
optional
>
{'Set environment variable to \'light\' or \'dark\''}
{' '}
to configure initial theme
{' '}
(defaults to {'\'system\''}):
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
</ChecklistRow>
<ChecklistRow
title="Photo matting"
status={arePhotosMatted}
optional
>
Set environment variable to {'"1"'} to constrain the size
{' '}
of each photo, and display a surrounding border:
<div className="pt-1 flex flex-col gap-1">
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" />
</div>
</ChecklistRow>
<ChecklistRow
title="Custom photo matting colors"
status={arePhotoMatteColorsConfigured}
optional
>
Set environment variable hex values (e.g., #cccccc)
to override matte colors:
<div className="pt-1 flex flex-col gap-1">
<EnvVar
variable="NEXT_PUBLIC_MATTE_COLOR"
accessory={matteColor && <span
className="size-[15px] border-medium rounded-sm ml-1"
style={{ backgroundColor: matteColor }}
/>}
/>
<EnvVar
variable="NEXT_PUBLIC_MATTE_COLOR_DARK"
accessory={matteColorDark && <span
className="size-[15px] border-medium rounded-sm ml-1"
style={{ backgroundColor: matteColorDark }}
/>}
/>
</div>
</ChecklistRow>
</>;
case 'Settings':
return <>
<ChecklistRow
title="Geo privacy"
status={isGeoPrivacyEnabled}
optional
>
Set environment variable to {'"1"'} to disable
collection/display of location-based data:
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Public downloads"
status={arePublicDownloadsEnabled}
optional
>
Set environment variable to {'"1"'} to enable
public photo downloads for all visitors:
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
</ChecklistRow>
<ChecklistRow
title="Site feeds (JSON/RSS)"
status={areSiteFeedsEnabled}
optional
>
Set environment variable to {'"1"'} to enable feeds at
{' '}
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
</ChecklistRow>
<ChecklistRow
title="Legacy OG text alignment"
status={isOgTextBottomAligned}
optional
>
Set environment variable to {'"BOTTOM"'} to
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</>;
case 'Internal':
return <>
<ChecklistRow
title="Debug tools"
status={areAdminDebugToolsEnabled}
optional
>
Set environment variable to {'"1"'} to temporarily enable
features like photo matting, baseline grid, etc.:
{renderEnvVars(['ADMIN_DEBUG_TOOLS'])}
</ChecklistRow>
<ChecklistRow
title="DB optimize"
status={isAdminDbOptimizeEnabled}
optional
>
Set environment variable to {'"1"'} to prevent
homepages from seeding infinite scroll on load:
{renderEnvVars(['ADMIN_DB_OPTIMIZE'])}
</ChecklistRow>
<ChecklistRow
title="SQL debugging"
status={isAdminSqlDebugEnabled}
optional
>
Set environment variable to {'"1"'} to enable
console output for all sql queries:
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
</ChecklistRow>
</>;
}
};
return (
<ScoreCardContainer>
{getAdminConfigSections(areInternalToolsEnabled, simplifiedView)
.map((section) => (
<ChecklistGroup
key={section.title}
title={section.title}
titleShort={(section as AdminConfigSection).titleShort}
icon={section.icon}
optional={!section.required}
updateHashOnVisible={hasScrolled}
>
{renderGroupContent(section.title)}
</ChecklistGroup>
))}
<div className="pl-11 pr-2 sm:pr-11 mt-4 md:mt-7">
<div>
Changes to environment variables require a redeploy
or reboot of local dev server
</div>
</div>
</ScoreCardContainer>
);
}

View File

@ -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 (
<div className={clsx(
'sticky top-0 pt-2.5 -mt-2.5',
'space-y-1 text-sm',
)}>
{getAdminConfigSections(areInternalToolsEnabled, simplifiedView)
.map(({ title }) => (
<a
key={title}
href={`#${parameterize(title)}`}
className={clsx(
'block',
parameterize(title) === hash
? 'text-main hover:text-main font-medium'
: 'text-dim',
)}
>
{title}
</a>
))}
</div>
);
}

View File

@ -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: <BiData size={16} className="translate-y-[0.5px]" />,
}, {
title: 'Authentication',
required: true,
icon: <BiLockAlt size={16} />,
}, {
title: 'Content',
required: true,
icon: <BiPencil size={16} />,
}, {
title: 'AI Text Generation',
titleShort: 'AI',
required: false,
icon: <HiSparkles size={14} />,
}, {
title: 'Performance',
required: false,
icon: <RiSpeedMiniLine size={19} />,
}, {
title: 'Categories',
required: false,
icon: <FaRegFolderClosed size={15} />,
}, {
title: 'Sorting',
required: false,
icon: <IconSort size={18} className="translate-y-[1px]" />,
}, {
title: 'Display',
required: false,
icon: <BiHide size={18} />,
}, {
title: 'Grid',
required: false,
icon: <IoMdGrid size={17} />,
}, {
title: 'Design',
required: false,
icon: <PiPaintBrushHousehold size={19} />,
}, {
title: 'Settings',
required: false,
icon: <HiOutlineCog size={17} className="translate-y-[0.5px]" />,
}, {
title: 'Internal',
required: false,
icon: <CgDebug size={18} />,
},
] 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'),
);

View File

@ -46,7 +46,7 @@ export default function Nav({
classNameStickyContainer, classNameStickyContainer,
classNameStickyNav, classNameStickyNav,
isNavVisible, isNavVisible,
} = useStickyNav(ref); } = useStickyNav(ref, !isPathAdmin(pathname));
const renderLink = ( const renderLink = (
text: string, text: string,
@ -112,7 +112,6 @@ export default function Nav({
: []} : []}
/> />
} }
sideHiddenOnMobile
/> />
); );
}; };

View File

@ -19,7 +19,7 @@ export default function AppGrid({
contentMain, contentMain,
contentSide, contentSide,
sideFirstOnMobile, sideFirstOnMobile,
sideHiddenOnMobile, sideHiddenOnMobile = true,
...props ...props
}: { }: {
containerRef?: RefObject<HTMLDivElement | null> containerRef?: RefObject<HTMLDivElement | null>
@ -58,7 +58,7 @@ export default function AppGrid({
'col-span-1 md:col-span-3', 'col-span-1 md:col-span-3',
'3xl:max-w-[260px]', '3xl:max-w-[260px]',
sideFirstOnMobile && 'order-1 md:order-none', sideFirstOnMobile && 'order-1 md:order-none',
sideHiddenOnMobile && 'hidden md:block', sideHiddenOnMobile && 'max-md:hidden',
classNameSide, classNameSide,
)}> )}>
{contentSide} {contentSide}

View File

@ -1,10 +1,11 @@
import { ReactNode } from 'react'; import { ReactNode, useRef } from 'react';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import ExperimentalBadge from './ExperimentalBadge'; import ExperimentalBadge from './ExperimentalBadge';
import Badge from './Badge'; import Badge from './Badge';
import ResponsiveText from './primitives/ResponsiveText'; import ResponsiveText from './primitives/ResponsiveText';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import ScoreCard from './ScoreCard'; import ScoreCard from './ScoreCard';
import useVisible from '@/utility/useVisible';
export default function ChecklistGroup({ export default function ChecklistGroup({
title, title,
@ -12,6 +13,7 @@ export default function ChecklistGroup({
icon, icon,
optional, optional,
experimental, experimental,
updateHashOnVisible,
children, children,
}: { }: {
title: string title: string
@ -19,21 +21,32 @@ export default function ChecklistGroup({
icon?: ReactNode icon?: ReactNode
optional?: boolean optional?: boolean
experimental?: boolean experimental?: boolean
updateHashOnVisible?: boolean
children: ReactNode children: ReactNode
}) { }) {
const ref = useRef<HTMLAnchorElement>(null);
const slug = parameterize(title); const slug = parameterize(title);
useVisible({ ref, onVisible: () => {
if (updateHashOnVisible) {
window.history.replaceState(null, '', `#${slug}`);
}
} });
return ( return (
<ScoreCard title={<a <ScoreCard title={<a
ref={ref}
id={slug} id={slug}
href={`#${slug}`} href={`#${slug}`}
className={clsx( className={clsx(
'inline-flex items-center', 'inline-flex items-center',
'text-gray-600 dark:text-gray-300', 'text-gray-600 dark:text-gray-300',
'pt-2',
'sm:pl-1.5', 'sm:pl-1.5',
)} )}
> >
<span className="w-8 sm:w-9 shrink-0">{icon}</span> <span className="w-8 sm:w-9 shrink-0 translate-y-[-1px]">{icon}</span>
<span className="inline-flex flex-wrap items-center gap-y-1 gap-x-2.5"> <span className="inline-flex flex-wrap items-center gap-y-1 gap-x-2.5">
<ResponsiveText shortText={titleShort}> <ResponsiveText shortText={titleShort}>
{title} {title}

View File

@ -14,7 +14,7 @@ export default function ScoreCard({
<div className="space-y-2"> <div className="space-y-2">
{title && {title &&
<div className={clsx( <div className={clsx(
'pl-[15px] h-6', 'pl-[15px] h-7 pb-1 flex items-end',
'uppercase font-medium tracking-wider text-[0.8rem]', 'uppercase font-medium tracking-wider text-[0.8rem]',
'text-medium', 'text-medium',
)}> )}>

View File

@ -8,7 +8,7 @@ export default function ScoreCardContainer({
}) { }) {
return <div className={clsx( return <div className={clsx(
'max-w-xl w-full', 'max-w-xl w-full',
'space-y-6 md:space-y-8', 'space-y-4 md:space-y-6',
)}> )}>
{children} {children}
</div>; </div>;

View File

@ -171,7 +171,6 @@ export default function PhotoDetailPage({
]} ]}
/> />
<AppGrid <AppGrid
sideFirstOnMobile
contentMain={<PhotoGrid contentMain={<PhotoGrid
photos={photosGrid ?? photos} photos={photosGrid ?? photos}
selectedPhoto={photo} selectedPhoto={photo}

View File

@ -72,7 +72,6 @@ export default function PhotoGridContainer({
</div> </div>
</div>} </div>}
contentSide={sidebar} contentSide={sidebar}
sideHiddenOnMobile
/> />
); );
} }

View File

@ -4,7 +4,7 @@ import {
IS_SITE_READY, IS_SITE_READY,
PRESERVE_ORIGINAL_UPLOADS, PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config'; } from '@/app/config';
import AdminAppConfiguration from '@/admin/AdminAppConfiguration'; import AdminAppConfiguration from '@/admin/config/AdminAppConfiguration';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { HiOutlinePhotograph } from 'react-icons/hi'; import { HiOutlinePhotograph } from 'react-icons/hi';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';

25
src/utility/useHash.ts Normal file
View File

@ -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('#', '');
}