Vercel/src/site/SiteChecklistClient.tsx
2024-06-11 17:17:27 -05:00

535 lines
17 KiB
TypeScript

'use client';
import {
ComponentProps,
ReactNode,
useTransition,
} from 'react';
import { useRouter } from 'next/navigation';
import { clsx } from 'clsx/lite';
import ChecklistRow from '../components/ChecklistRow';
import { FiExternalLink } from 'react-icons/fi';
import {
BiCog,
BiCopy,
BiData,
BiLockAlt,
BiPencil,
BiRefresh,
} from 'react-icons/bi';
import InfoBlock from '@/components/InfoBlock';
import Checklist from '@/components/Checklist';
import { toastSuccess } from '@/toast';
import { ConfigChecklistStatus } from './config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
import { HiSparkles } from 'react-icons/hi';
import LoaderButton from '@/components/primitives/LoaderButton';
import { testConnectionsAction } from '@/admin/actions';
import ErrorNote from '@/components/ErrorNote';
export default function SiteChecklistClient({
// Config checklist
hasDatabase,
isPostgresSSLEnabled,
hasVercelPostgres,
hasVercelKV,
hasStorageProvider,
hasVercelBlobStorage,
hasCloudflareR2Storage,
hasAwsS3Storage,
hasMultipleStorageProviders,
currentStorage,
hasAuthSecret,
hasAdminUser,
hasTitle,
hasDomain,
showRepoLink,
showSocial,
showFilmSimulations,
showExifInfo,
isProModeEnabled,
isStaticallyOptimized,
arePagesStaticallyOptimized,
areOGImagesStaticallyOptimized,
arePhotosMatted,
isBlurEnabled,
isGeoPrivacyEnabled,
isPriorityOrderEnabled,
isAiTextGenerationEnabled,
aiTextAutoGeneratedFields,
hasAiTextAutoGeneratedFields,
isPublicApiEnabled,
isOgTextBottomAligned,
gridAspectRatio,
hasGridAspectRatio,
// Connection status
databaseError,
storageError,
aiError,
// Component props
simplifiedView,
showRefreshButton,
secret,
}: ConfigChecklistStatus &
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean
showRefreshButton?: boolean
secret: string
}) {
const router = useRouter();
const [isPendingPage, startTransitionPage] = useTransition();
const [isPendingSecret, startTransitionSecret] = useTransition();
const refreshPage = () => {
startTransitionPage(router.refresh);
};
const refreshSecret = () => {
startTransitionSecret(router.refresh);
};
const renderLink = (href: string, text: string, external = true) =>
<>
<a {...{
href,
...external && { target: '_blank', rel: 'noopener noreferrer' },
className: clsx(
'underline hover:no-underline',
),
}}>
{text}
</a>
{external &&
<>
&nbsp;
<FiExternalLink
size={14}
className='inline translate-y-[-1.5px]'
/>
</>}
</>;
const renderCopyButton = (label: string, text: string, subtle?: boolean) =>
<LoaderButton
icon={<BiCopy size={15} />}
className={clsx(
'translate-y-[2px]',
subtle && 'text-gray-300 dark:text-gray-700',
)}
onClick={() => {
navigator.clipboard.writeText(text);
toastSuccess(`${label} copied to clipboard`);
}}
styleAs="link"
/>;
const renderEnvVar = (
variable: string,
minimal?: boolean,
) =>
<div
key={variable}
className={clsx(
'overflow-x-auto overflow-y-hidden',
minimal && 'inline-flex',
)}
>
<span className="inline-flex items-center gap-1">
<span className={clsx(
'text-[11px] font-medium tracking-wider',
'px-0.5 py-[0.5px]',
'rounded-[5px]',
'bg-gray-100 dark:bg-gray-800',
)}>
`{variable}`
</span>
{!minimal && renderCopyButton(variable, variable, true)}
</span>
</div>;
const renderEnvVars = (variables: string[]) =>
<div className="pt-1 space-y-1">
{variables.map(envVar => renderEnvVar(envVar))}
</div>;
const renderSubStatus = (
type: ComponentProps<typeof StatusIcon>['type'],
label: ReactNode,
iconClassName?: string,
) =>
<div className="flex gap-2 translate-x-[-3px]">
<span className={iconClassName}>
<StatusIcon {...{ type }} />
</span>
<span className="min-w-0">
{label}
</span>
</div>;
const renderConnectionError = (provider: string, error: string) =>
<ErrorNote size="small" className="mt-2 mb-3">
{provider} connection error: {`"${error}"`}
</ErrorNote>;
return (
<div className="max-w-xl space-y-6 w-full">
<Checklist
title="Storage"
icon={<BiData size={16} />}
>
<ChecklistRow
title="Setup database"
status={hasDatabase}
isPending={isPendingPage}
>
{databaseError &&
renderConnectionError('Database', databaseError)}
{hasVercelPostgres
? renderSubStatus('checked', 'Vercel Postgres: connected')
: renderSubStatus('optional', <>
Vercel Postgres:
{' '}
{renderLink(
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-postgres/quickstart#create-a-postgres-database',
'create store',
)}
{' '}
and connect to project
</>)}
{hasDatabase && !hasVercelPostgres &&
renderSubStatus('checked', <>
Postgres-compatible: connected
{' '}
(SSL {isPostgresSSLEnabled ? 'enabled' : 'disabled'})
</>)}
</ChecklistRow>
<ChecklistRow
title={!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={isPendingPage}
>
{storageError &&
renderConnectionError('Storage', storageError)}
{hasVercelBlobStorage
? renderSubStatus('checked', 'Vercel Blob: connected')
: renderSubStatus('optional', <>
{labelForStorage('vercel-blob')}:
{' '}
{renderLink(
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-blob/quickstart#create-a-blob-store',
'create store',
)}
{' '}
and connect to project
</>
)}
{hasCloudflareR2Storage
? renderSubStatus('checked', 'Cloudflare R2: connected')
: renderSubStatus('optional', <>
{labelForStorage('cloudflare-r2')}:
{' '}
{renderLink(
'https://github.com/sambecker/exif-photo-blog#cloudflare-r2',
'create/configure bucket',
)}
</>)}
{hasAwsS3Storage
? renderSubStatus('checked', 'AWS S3: connected')
: renderSubStatus('optional', <>
{labelForStorage('aws-s3')}:
{' '}
{renderLink(
'https://github.com/sambecker/exif-photo-blog#aws-s3',
'create/configure bucket',
)}
</>)}
</ChecklistRow>
</Checklist>
<Checklist
title="Authentication"
icon={<BiLockAlt size={16} />}
>
<ChecklistRow
title="Setup auth"
status={hasAuthSecret}
isPending={isPendingPage}
>
Store auth secret in environment variable:
{!hasAuthSecret &&
<div className="overflow-x-auto">
<InfoBlock className="my-1.5 inline-flex" padding="tight">
<div className={clsx(
'flex flex-nowrap items-center gap-2 leading-none -mx-1',
)}>
<span>{secret}</span>
<div className="flex items-center gap-0.5 translate-y-[-2px]">
{renderCopyButton('Secret', secret)}
<LoaderButton
icon={<BiRefresh size={18} />}
onClick={refreshSecret}
isLoading={isPendingSecret}
spinnerColor="text"
styleAs="link"
/>
</div>
</div>
</InfoBlock>
</div>}
{renderEnvVars(['AUTH_SECRET'])}
</ChecklistRow>
<ChecklistRow
title="Setup admin user"
status={hasAdminUser}
isPending={isPendingPage}
>
Store admin email/password
{' '}
in environment variables:
{renderEnvVars([
'ADMIN_EMAIL',
'ADMIN_PASSWORD',
])}
</ChecklistRow>
</Checklist>
<Checklist
title="Content"
icon={<BiPencil size={16} />}
optional
>
<ChecklistRow
title="Add title"
status={hasTitle}
isPending={isPendingPage}
optional
>
Store in environment variable (used in page titles):
{renderEnvVars(['NEXT_PUBLIC_SITE_TITLE'])}
</ChecklistRow>
<ChecklistRow
title="Add custom domain"
status={hasDomain}
isPending={isPendingPage}
optional
>
Store in environment variable (displayed in top-right nav):
{renderEnvVars(['NEXT_PUBLIC_SITE_DOMAIN'])}
</ChecklistRow>
</Checklist>
{!simplifiedView && <>
<Checklist
title="AI Text Generation"
icon={<HiSparkles />}
experimental
optional
>
<ChecklistRow
title="Add OpenAI Secret Key"
status={isAiTextGenerationEnabled}
isPending={isPendingPage}
optional
>
{aiError &&
renderConnectionError('OpenAI', aiError)}
Store your OpenAI secret key in order to add experimental support
for AI-generated text descriptions and enable an invisible field
called {'"Semantic Description"'} used to support CMD-K search
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow>
<ChecklistRow
title="Enable Rate Limiting"
status={hasVercelKV}
isPending={isPendingPage}
optional
>
{renderLink(
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
'Create Vercel KV store',
)}
{' '}
and connect to project in order to enable rate limiting
</ChecklistRow>
<ChecklistRow
// eslint-disable-next-line max-len
title={`Auto-generated fields: ${aiTextAutoGeneratedFields.join(', ')}`}
status={hasAiTextAutoGeneratedFields}
isPending={isPendingPage}
optional
>
Comma-separated fields to auto-generate when
uploading photos. Accepted values: title, caption,
tags, description, all, or none (default is {'"all"'}).
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Settings"
icon={<BiCog size={16} />}
optional
>
<ChecklistRow
title="Pro mode"
status={isProModeEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to enable
higher quality image storage:
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
</ChecklistRow>
<ChecklistRow
title="Static Optimization"
status={isStaticallyOptimized}
isPending={isPendingPage}
optional
experimental
>
Set environment variable to {'"1"'} to enable static optimization,
i.e., rendering pages and images at build time:
{renderSubStatus(
arePagesStaticallyOptimized ? 'checked' : 'optional',
renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES']),
'translate-y-[3.5px]',
)}
{renderSubStatus(
areOGImagesStaticallyOptimized ? 'checked' : 'optional',
renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES']),
'translate-y-[3.5px]',
)}
</ChecklistRow>
<ChecklistRow
title="Photo Matting"
status={arePhotosMatted}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to constrain the size
{' '}
of each photo, and enable a surrounding border:
{renderEnvVars(['NEXT_PUBLIC_MATTE_PHOTOS'])}
</ChecklistRow>
<ChecklistRow
title="Image Blur"
status={isBlurEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
<ChecklistRow
title="Geo privacy"
status={isGeoPrivacyEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to disable
collection/display of location-based data
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Priority order"
status={isPriorityOrderEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
priority order photo field affecting photo order
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
</ChecklistRow>
<ChecklistRow
title="Public API"
status={isPublicApiEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to enable
a public API available at <code>/api</code>:
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
<ChecklistRow
title="Show social"
status={showSocial}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide
{' '}
X button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm simulations"
status={showFilmSimulations}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
simulations showing up in /grid sidebar and
CMD-K search results:
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
</ChecklistRow>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
isPending={isPendingPage}
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="Legacy OG text alignment"
status={isOgTextBottomAligned}
isPending={isPendingPage}
optional
>
Set environment variable to {'"BOTTOM"'} to
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</Checklist>
</>}
{showRefreshButton &&
<div className="py-4 space-y-4">
<button onClick={refreshPage}>
Check
</button>
</div>}
<div className="px-11 text-dim">
Changes to environment variables require a redeploy
or reboot of local dev server
</div>
</div>
);
}