Add section menu to admin app config
This commit is contained in:
parent
188c704589
commit
aefda2db19
@ -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 (
|
||||
<AdminInfoPage>
|
||||
<AdminInfoPage
|
||||
// Necessary because of useSearchParams usage in sidebar anchors
|
||||
contentSide={<Suspense>
|
||||
<AdminAppConfigurationSidebar
|
||||
{...{ areInternalToolsEnabled }}
|
||||
/>
|
||||
</Suspense>}
|
||||
>
|
||||
<AdminAppConfiguration />
|
||||
</AdminInfoPage>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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<ComponentProps<typeof AppGrid>, 'contentMain'>) {
|
||||
return (
|
||||
<AppGrid
|
||||
{...props}
|
||||
contentMain={
|
||||
<Container spaceChildren={false}>
|
||||
{children}
|
||||
|
||||
@ -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,
|
||||
881
src/admin/config/AdminAppConfigurationClient.tsx
Normal file
881
src/admin/config/AdminAppConfigurationClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/admin/config/AdminAppConfigurationSidebar.tsx
Normal file
38
src/admin/config/AdminAppConfigurationSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/admin/config/index.tsx
Normal file
78
src/admin/config/index.tsx
Normal 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'),
|
||||
);
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -19,7 +19,7 @@ export default function AppGrid({
|
||||
contentMain,
|
||||
contentSide,
|
||||
sideFirstOnMobile,
|
||||
sideHiddenOnMobile,
|
||||
sideHiddenOnMobile = true,
|
||||
...props
|
||||
}: {
|
||||
containerRef?: RefObject<HTMLDivElement | null>
|
||||
@ -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}
|
||||
|
||||
@ -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<HTMLAnchorElement>(null);
|
||||
|
||||
const slug = parameterize(title);
|
||||
|
||||
useVisible({ ref, onVisible: () => {
|
||||
if (updateHashOnVisible) {
|
||||
window.history.replaceState(null, '', `#${slug}`);
|
||||
}
|
||||
} });
|
||||
|
||||
return (
|
||||
<ScoreCard title={<a
|
||||
ref={ref}
|
||||
id={slug}
|
||||
href={`#${slug}`}
|
||||
className={clsx(
|
||||
'inline-flex items-center',
|
||||
'text-gray-600 dark:text-gray-300',
|
||||
'pt-2',
|
||||
'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">
|
||||
<ResponsiveText shortText={titleShort}>
|
||||
{title}
|
||||
|
||||
@ -14,7 +14,7 @@ export default function ScoreCard({
|
||||
<div className="space-y-2">
|
||||
{title &&
|
||||
<div className={clsx(
|
||||
'pl-[15px] h-6',
|
||||
'pl-[15px] h-7 pb-1 flex items-end',
|
||||
'uppercase font-medium tracking-wider text-[0.8rem]',
|
||||
'text-medium',
|
||||
)}>
|
||||
|
||||
@ -8,7 +8,7 @@ export default function ScoreCardContainer({
|
||||
}) {
|
||||
return <div className={clsx(
|
||||
'max-w-xl w-full',
|
||||
'space-y-6 md:space-y-8',
|
||||
'space-y-4 md:space-y-6',
|
||||
)}>
|
||||
{children}
|
||||
</div>;
|
||||
|
||||
@ -171,7 +171,6 @@ export default function PhotoDetailPage({
|
||||
]}
|
||||
/>
|
||||
<AppGrid
|
||||
sideFirstOnMobile
|
||||
contentMain={<PhotoGrid
|
||||
photos={photosGrid ?? photos}
|
||||
selectedPhoto={photo}
|
||||
|
||||
@ -72,7 +72,6 @@ export default function PhotoGridContainer({
|
||||
</div>
|
||||
</div>}
|
||||
contentSide={sidebar}
|
||||
sideHiddenOnMobile
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
25
src/utility/useHash.ts
Normal file
25
src/utility/useHash.ts
Normal 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('#', '');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user