Finalize core insights UX

This commit is contained in:
Sam Becker 2025-02-14 18:06:53 -06:00
parent cc02829849
commit e1082a8a3d
8 changed files with 233 additions and 85 deletions

View File

@ -67,6 +67,7 @@ export default function AdminOutdatedClient({
} }
}} }}
isLoading={arePhotoIdsSyncing} isLoading={arePhotoIdsSyncing}
disabled={!updateBatchSize}
> >
{arePhotoIdsSyncing {arePhotoIdsSyncing
? 'Syncing' ? 'Syncing'

View File

@ -19,6 +19,7 @@ import {
VERCEL_GIT_REPO_SLUG, VERCEL_GIT_REPO_SLUG,
} from '@/app-core/config'; } from '@/app-core/config';
import { getGitHubMetaWithFallback } from '../github'; import { getGitHubMetaWithFallback } from '../github';
import { OUTDATED_THRESHOLD } from '@/photo';
const BASIC_PHOTO_INSTALLATION_COUNT = 32; const BASIC_PHOTO_INSTALLATION_COUNT = 32;
@ -31,6 +32,7 @@ export default async function AdminAppInsights() {
const [ const [
{ count: photosCount, dateRange }, { count: photosCount, dateRange },
{ count: photosCountHidden }, { count: photosCountHidden },
{ count: photosCountOutdated },
{ count: photosCountPortrait }, { count: photosCountPortrait },
tags, tags,
cameras, cameras,
@ -39,6 +41,7 @@ export default async function AdminAppInsights() {
] = await Promise.all([ ] = await Promise.all([
getPhotosMeta({ hidden: 'include' }), getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }), getPhotosMeta({ hidden: 'only' }),
getPhotosMeta({ hidden: 'include', updatedBefore: OUTDATED_THRESHOLD }),
getPhotosMeta({ maximumAspectRatio: 0.9 }), getPhotosMeta({ maximumAspectRatio: 0.9 }),
getUniqueTags(), getUniqueTags(),
getUniqueCameras(), getUniqueCameras(),
@ -63,11 +66,12 @@ export default async function AdminAppInsights() {
return ( return (
<AdminAppInsightsClient <AdminAppInsightsClient
codeMeta={codeMeta} codeMeta={codeMeta}
recommendations={{ insights={{
noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo, noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo,
forkBehind: Boolean(codeMeta?.isBehind), forkBehind: Boolean(codeMeta?.isBehind),
noAi: !isAiTextGenerationEnabled, noAi: !isAiTextGenerationEnabled,
noAiRateLimiting: isAiTextGenerationEnabled && !hasVercelBlobStorage, noAiRateLimiting: isAiTextGenerationEnabled && !hasVercelBlobStorage,
outdatedPhotos: Boolean(photosCountOutdated),
photoMatting: photosCountPortrait > 0 && !MATTE_PHOTOS, photoMatting: photosCountPortrait > 0 && !MATTE_PHOTOS,
gridFirst: ( gridFirst: (
photosCount >= BASIC_PHOTO_INSTALLATION_COUNT && photosCount >= BASIC_PHOTO_INSTALLATION_COUNT &&
@ -78,6 +82,7 @@ export default async function AdminAppInsights() {
photoStats={{ photoStats={{
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountOutdated,
tagsCount: tags.length, tagsCount: tags.length,
camerasCount: cameras.length, camerasCount: cameras.length,
filmSimulationsCount: filmSimulations.length, filmSimulationsCount: filmSimulations.length,

View File

@ -1,20 +1,16 @@
'use client'; 'use client';
import IconGrSync from '@/app-core/IconGrSync';
import ScoreCard from '@/components/ScoreCard'; import ScoreCard from '@/components/ScoreCard';
import ScoreCardRow from '@/components/ScoreCardRow'; import ScoreCardRow from '@/components/ScoreCardRow';
import { dateRangeForPhotos, PhotoDateRange } from '@/photo'; import { dateRangeForPhotos } from '@/photo';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { FaCamera } from 'react-icons/fa'; import { FaCamera } from 'react-icons/fa';
import { FaTag } from 'react-icons/fa'; import { FaTag } from 'react-icons/fa';
import { FaRegCalendar } from 'react-icons/fa6'; import { FaCircleInfo, FaRegCalendar } from 'react-icons/fa6';
import { import { HiOutlinePhotograph } from 'react-icons/hi';
HiOutlinePhotograph, import { MdAspectRatio } from 'react-icons/md';
HiSparkles,
} from 'react-icons/hi';
import { MdLightbulbOutline } from 'react-icons/md';
import { PiWarningBold } from 'react-icons/pi'; import { PiWarningBold } from 'react-icons/pi';
import { TbCone } from 'react-icons/tb'; import { TbCone, TbSparkles } from 'react-icons/tb';
import { getGitHubMetaWithFallback } from '../github'; import { getGitHubMetaWithFallback } from '../github';
import { BiGitBranch, BiGitCommit, BiLogoGithub } from 'react-icons/bi'; import { BiGitBranch, BiGitCommit, BiLogoGithub } from 'react-icons/bi';
import { import {
@ -23,24 +19,40 @@ import {
TEMPLATE_REPO_NAME, TEMPLATE_REPO_NAME,
VERCEL_GIT_COMMIT_SHA_SHORT, VERCEL_GIT_COMMIT_SHA_SHORT,
VERCEL_GIT_COMMIT_MESSAGE, VERCEL_GIT_COMMIT_MESSAGE,
TEMPLATE_REPO_URL_FORK,
TEMPLATE_REPO_URL_README,
} from '@/app-core/config'; } from '@/app-core/config';
import { AdminAppInsight } from '.'; import { AdminAppInsights, hasTemplateRecommendations, PhotoStats } from '.';
import EnvVar from '@/components/EnvVar'; import EnvVar from '@/components/EnvVar';
import { IoSyncCircle } from 'react-icons/io5'; import { IoSyncCircle } from 'react-icons/io5';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { PATH_ADMIN_OUTDATED } from '@/app-core/paths';
import { LiaBroomSolid } from 'react-icons/lia';
import { IoMdGrid } from 'react-icons/io';
import { RiSpeedMiniLine } from 'react-icons/ri';
import LinkWithStatus from '@/components/LinkWithStatus';
const readmeAnchor = (anchor: string, text: string) =>
<a
href={`${TEMPLATE_REPO_URL_README}#${anchor}}`}
target="blank"
className="underline"
>
{text}
</a>;
const DEBUG_COMMIT_SHA = '4cd29ed'; const DEBUG_COMMIT_SHA = '4cd29ed';
const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes'; const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes';
const DEBUG_BEHIND_BY = 9;
const DEBUG_PHOTOS_COUNT_OUTDATED = 7;
export default function AdminAppInsightsClient({ export default function AdminAppInsightsClient({
codeMeta, codeMeta,
recommendations: { insights,
noAi,
noAiRateLimiting,
},
photoStats: { photoStats: {
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountOutdated,
tagsCount, tagsCount,
camerasCount, camerasCount,
filmSimulationsCount, filmSimulationsCount,
@ -50,18 +62,20 @@ export default function AdminAppInsightsClient({
debug, debug,
}: { }: {
codeMeta?: Awaited<ReturnType<typeof getGitHubMetaWithFallback>> codeMeta?: Awaited<ReturnType<typeof getGitHubMetaWithFallback>>
recommendations: Record<AdminAppInsight, boolean> insights: AdminAppInsights
photoStats: { photoStats: PhotoStats
photosCount: number
photosCountHidden: number
tagsCount: number
camerasCount: number
filmSimulationsCount: number
lensesCount: number
dateRange?: PhotoDateRange
},
debug?: boolean debug?: boolean
}) { }) {
const {
noFork,
forkBehind,
noAi,
noAiRateLimiting,
outdatedPhotos,
photoMatting,
gridFirst,
noStaticOptimization,
} = insights;
const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange); const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange);
@ -69,37 +83,63 @@ export default function AdminAppInsightsClient({
<div className="space-y-6 md:space-y-8"> <div className="space-y-6 md:space-y-8">
{(codeMeta?.isBaseRepo || codeMeta?.isForkedFromBase || debug) && <> {(codeMeta?.isBaseRepo || codeMeta?.isForkedFromBase || debug) && <>
<ScoreCard title="Build details"> <ScoreCard title="Build details">
{(codeMeta?.behindBy || debug) && {(noFork || debug) &&
<ScoreCardRow <ScoreCardRow
icon={<IoSyncCircle icon={<FaCircleInfo
size={18} size={15}
className="text-blue-500" className="text-blue-500 translate-y-[1px]"
/>} />}
content={<> content="This template is not forked"
This fork is expandContent={<>
{' '}
<span className={clsx(
'text-blue-600 bg-blue-100/60',
'dark:text-blue-400 dark:bg-blue-900/50',
'px-1.5 pt-[1px] pb-0.5 rounded-md',
)}>
{codeMeta?.behindBy ?? 9} commits
</span>
{' '}
behind
</>}
additionalContent={<>
<a <a
href={codeMeta?.urlRepo} href={TEMPLATE_REPO_URL_FORK}
target="blank" target="blank"
className="underline" className="underline"
> >
Sync your fork Fork
</a> </a>
{' '} {' '}
to receive the latest fixes and features original template to receive the latest fixes and features.
{' '}
{readmeAnchor('receiving-updates', 'Additional instructions')}
{' '}
in README.
</>} </>}
/>} />}
{(forkBehind || debug) && <ScoreCardRow
icon={<IoSyncCircle
size={18}
className="text-blue-500"
/>}
content={<>
This fork is
{' '}
<span className={clsx(
'text-blue-600 bg-blue-100/60',
'dark:text-blue-400 dark:bg-blue-900/50',
'px-1.5 pt-[1px] pb-0.5 rounded-md',
)}>
{codeMeta?.behindBy ?? DEBUG_BEHIND_BY}
{' '}
{(codeMeta?.behindBy ?? DEBUG_BEHIND_BY) === 1
? 'commit'
: 'commits'}
</span>
{' '}
behind
</>}
expandContent={<>
<a
href={codeMeta?.urlRepo}
target="blank"
className="underline"
>
Sync your fork
</a>
{' '}
to receive the latest fixes and features
</>}
/>}
<ScoreCardRow <ScoreCardRow
icon={<BiLogoGithub size={17} />} icon={<BiLogoGithub size={17} />}
content={<div content={<div
@ -152,42 +192,104 @@ export default function AdminAppInsightsClient({
/> />
</ScoreCard> </ScoreCard>
</>} </>}
<ScoreCard title="Template recommendations"> {(hasTemplateRecommendations(insights) || debug) &&
{(noAiRateLimiting || debug) && <ScoreCardRow <ScoreCard title="Template recommendations">
icon={<PiWarningBold {(noAiRateLimiting || debug) && <ScoreCardRow
size={17} icon={<PiWarningBold
className="translate-x-[0.5px] text-amber-600" size={17}
className="translate-x-[0.5px] text-amber-600"
/>}
content="AI enabled without rate limiting"
// eslint-disable-next-line max-len
expandContent="Create Vercel KV store and link o this project in order to enable rate limiting."
/>} />}
content="AI enabled without rate limiting" {(noAi || debug) && <ScoreCardRow
// eslint-disable-next-line max-len icon={<TbSparkles size={17} />}
additionalContent="Create Vercel KV store and link o this project in order to enable rate limiting." content="Improve SEO + accessibility with AI"
/>} expandContent={<>
{(noAi || debug) && <ScoreCardRow <div>
icon={<MdLightbulbOutline size={19} />} Enable automatic AI text generation
content="Enable AI text generation to improve photo descriptions" {' '}
// eslint-disable-next-line max-len by setting environment variable
additionalContent="Create Vercel KV store and link it to this project in order to enable rate limiting." {' '}
/>} <EnvVar variable="OPENAI_SECRET_KEY" />.
<ScoreCardRow </div>
icon={<MdLightbulbOutline size={19} />} <div>
// eslint-disable-next-line max-len Further instruction in
content="You seem to have several vertical photos—consider enabling matting to make portrait and landscape photos appear more consistent" {' '}
additionalContent={<> {readmeAnchor('ai-text-generation', 'README')}.
Enabled photo matting by setting </div>
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" value="1" /> </>}
</>} />}
/> {(photoMatting || debug) && <ScoreCardRow
<ScoreCardRow // eslint-disable-next-line max-len
icon={<IconGrSync />} icon={<MdAspectRatio size={17} className="rotate-90 translate-x-[-1px]" />}
// eslint-disable-next-line max-len content="Vertical photos may benefit from matting"
content="Consider forking this repository to receive new features and fixes" expandContent={<>
/> {/* eslint-disable-next-line max-len */}
<ScoreCardRow Enable photo matting to make portrait and landscape photos appear more consistent
icon={<HiSparkles />} <EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" value="1" />
content="Enable AI text generation in the app configuration" </>}
/> />}
</ScoreCard> {(gridFirst || debug) && <ScoreCardRow
icon={<IoMdGrid size={18} className="translate-y-[-1px]" />}
content="Grid homepage"
expandContent={<>
Enable grid homepage by setting environment variable
{' '}
<EnvVar variable="NEXT_PUBLIC_GRID_HOMEPAGE_ENABLED" value="1" />
</>}
/>}
{(noStaticOptimization || debug) && <ScoreCardRow
icon={<RiSpeedMiniLine
size={19}
className="translate-x-[1px] translate-y-[-1.5px]"
/>}
content="Static optimization"
expandContent={<>
{/* eslint-disable-next-line max-len */}
Enable static optimization by setting any of the following environment variables:
<div className="flex flex-col gap-y-1 mt-3">
<EnvVar
variable="NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS"
value="1"
/>
<EnvVar
variable="NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES"
value="1"
/>
<EnvVar
variable="NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES"
value="1"
/>
<EnvVar
// eslint-disable-next-line max-len
variable="NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES"
value="1"
/>
</div>
</>}
/>}
</ScoreCard>}
<ScoreCard title="Library Stats"> <ScoreCard title="Library Stats">
{(outdatedPhotos || debug) && <ScoreCardRow
icon={<LiaBroomSolid
size={19}
className="translate-y-[-2px] text-amber-600"
/>}
// eslint-disable-next-line max-len
content={`You have ${photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED} outdated ${(photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED) === 1 ? 'photo' : 'photos'}`}
expandContent={<>
<LinkWithStatus
href={PATH_ADMIN_OUTDATED}
className="underline"
>
View outdated photos
</LinkWithStatus>
{' '}
to update them in batches.
</>}
/>}
<ScoreCardRow <ScoreCardRow
icon={<HiOutlinePhotograph icon={<HiOutlinePhotograph
size={17} size={17}

View File

@ -1,8 +1,44 @@
import { PhotoDateRange } from '@/photo';
export type AdminAppInsight = export type AdminAppInsight =
'noFork' | 'noFork' |
'forkBehind' | 'forkBehind' |
'noAi' | 'noAi' |
'noAiRateLimiting' | 'noAiRateLimiting' |
'outdatedPhotos' |
'photoMatting' | 'photoMatting' |
'gridFirst' | 'gridFirst' |
'noStaticOptimization'; 'noStaticOptimization';
const RECOMMENDATIONS: AdminAppInsight[] = [
'noAi',
'noAiRateLimiting',
'photoMatting',
'gridFirst',
'noStaticOptimization',
];
export type AdminAppInsights = Record<AdminAppInsight, boolean>
export const hasTemplateRecommendations = (insights: AdminAppInsights) =>
RECOMMENDATIONS.some(insight => insights[insight]);
export interface PhotoStats {
photosCount: number
photosCountHidden: number
photosCountOutdated: number
tagsCount: number
camerasCount: number
filmSimulationsCount: number
lensesCount: number
dateRange?: PhotoDateRange
}
export const getInsightIndicator = ({
forkBehind,
noAiRateLimiting,
outdatedPhotos,
}: AdminAppInsights) =>
forkBehind ||
noAiRateLimiting ||
outdatedPhotos;

View File

@ -21,6 +21,9 @@ export const TEMPLATE_REPO_NAME = 'exif-photo-blog';
export const TEMPLATE_REPO_BRANCH = 'main'; export const TEMPLATE_REPO_BRANCH = 'main';
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
export const TEMPLATE_REPO_URL = `https://github.com/${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`; export const TEMPLATE_REPO_URL = `https://github.com/${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`;
export const TEMPLATE_REPO_URL_FORK = `${TEMPLATE_REPO_URL}/fork`;
// eslint-disable-next-line max-len
export const TEMPLATE_REPO_URL_README = `${TEMPLATE_REPO_URL}?tab=readme-ov-file`;
export const VERCEL_GIT_PROVIDER = export const VERCEL_GIT_PROVIDER =
process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER; process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER;

View File

@ -23,6 +23,7 @@ export default function EnvVar({
'px-1.5 py-[0.5px]', 'px-1.5 py-[0.5px]',
'rounded-md', 'rounded-md',
'bg-gray-100 dark:bg-gray-800', 'bg-gray-100 dark:bg-gray-800',
'whitespace-nowrap',
)}> )}>
{variable}{value && ` = ${value}`} {variable}{value && ` = ${value}`}
</span> </span>

View File

@ -89,7 +89,7 @@ export default function LinkWithStatus({
{...props } {...props }
href={href} href={href}
className={clsx( className={clsx(
'relative flex transition-[colors,opacity]', 'relative inline-flex transition-[colors,opacity]',
(loadingClassName || isControlled) (loadingClassName || isControlled)
? 'opacity-100' ? 'opacity-100'
: isLoading ? 'opacity-50' : 'opacity-100', : isLoading ? 'opacity-50' : 'opacity-100',

View File

@ -5,12 +5,12 @@ import { LuChevronsDownUp, LuChevronsUpDown } from 'react-icons/lu';
export default function ScoreCardRow({ export default function ScoreCardRow({
icon, icon,
content, content,
additionalContent, expandContent,
className, className,
}: { }: {
icon: ReactNode icon: ReactNode
content: ReactNode content: ReactNode
additionalContent?: ReactNode expandContent?: ReactNode
className?: string className?: string
}) { }) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@ -33,10 +33,10 @@ export default function ScoreCardRow({
</div> </div>
{isExpanded && {isExpanded &&
<div className="text-medium"> <div className="text-medium">
{additionalContent} {expandContent}
</div>} </div>}
</div> </div>
{additionalContent && <button {expandContent && <button
type="button" type="button"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className={clsx( className={clsx(