Offer sidebar ordering with paired insight

This commit is contained in:
Sam Becker 2025-03-01 13:00:43 -06:00
parent d52bc017cb
commit 74e91be001
16 changed files with 237 additions and 158 deletions

View File

@ -132,6 +132,7 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results
- `NEXT_PUBLIC_HIDE_RECIPES = 1` prevents Fujifilm recipe button showing up in photo meta
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
- `NEXT_PUBLIC_CAMERAS_FIRST = 1` shows cameras above tags in grid sidebar
#### Grid
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
@ -143,7 +144,6 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
- `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
## Alternate storage providers

View File

@ -75,6 +75,7 @@ export default function AdminAppConfigurationClient({
showFilmSimulations,
showRecipes,
showRepoLink,
showSidebarCamerasFirst,
// Grid
isGridHomepageEnabled,
gridAspectRatio,
@ -543,6 +544,15 @@ export default function AdminAppConfigurationClient({
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
<ChecklistRow
title="Show cameras first"
status={showSidebarCamerasFirst}
optional
>
Set environment variable to {'"1"'} to show cameras
above tags in grid sidebar:
{renderEnvVars(['NEXT_PUBLIC_CAMERAS_FIRST'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup
title="Grid"

View File

@ -10,7 +10,7 @@ export default function AdminAppInfoIcon({
size?: 'small' | 'large'
className?: string
}) {
const { insightIndicatorStatus } = useAppState();
const { insightsIndicatorStatus } = useAppState();
return (
<span className={clsx(
@ -22,7 +22,7 @@ export default function AdminAppInfoIcon({
className="inline-flex translate-y-[1px]"
aria-label="App Info"
/>
{insightIndicatorStatus &&
{insightsIndicatorStatus &&
<InsightsIndicatorDot
size={size}
top={size === 'large' ? 1.5 : 1.5}

View File

@ -37,7 +37,7 @@ export default function AdminInfoPage({
const hasMultiplePages = pages.length > 1;
const { insightIndicatorStatus } = useAppState();
const { insightsIndicatorStatus } = useAppState();
return (
<div className="flex items-center gap-4 min-h-9">
@ -65,7 +65,7 @@ export default function AdminInfoPage({
<ResponsiveText shortText={titleShort}>
{title}
</ResponsiveText>
{title === 'App Insights' && insightIndicatorStatus &&
{title === 'App Insights' && insightsIndicatorStatus &&
<InsightsIndicatorDot
size="small"
top={4}

View File

@ -8,7 +8,7 @@ import { testStorageConnection } from '@/platforms/storage';
import { APP_CONFIGURATION } from '@/app/config';
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { getPhotosMetaCached, getUniqueTagsCached } from '@/photo/cache';
import { getShouldShowInsightsIndicator } from '@/admin/insights/server';
import { getInsightsIndicatorStatus } from '@/admin/insights/server';
export const getAdminDataAction = async () =>
runAuthenticatedAdminServerAction(async () => {
@ -17,7 +17,7 @@ export const getAdminDataAction = async () =>
countHiddenPhotos,
countTags,
countUploads,
shouldShowInsightsIndicator,
insightsIndicatorStatus,
] = await Promise.all([
getPhotosMetaCached()
.then(({ count }) => count)
@ -34,7 +34,7 @@ export const getAdminDataAction = async () =>
console.error(`Error getting blob upload urls: ${e}`);
return 0;
}),
getShouldShowInsightsIndicator(),
getInsightsIndicatorStatus(),
]);
return {
@ -42,7 +42,7 @@ export const getAdminDataAction = async () =>
countHiddenPhotos,
countTags,
countUploads,
shouldShowInsightsIndicator,
insightsIndicatorStatus,
};
});

View File

@ -11,11 +11,13 @@ import {
GRID_HOMEPAGE_ENABLED,
HAS_STATIC_OPTIMIZATION,
MATTE_PHOTOS,
SHOW_SIDEBAR_CAMERAS_FIRST,
} from '@/app/config';
import { getGitHubMetaForCurrentApp, getSignificantInsights } from '.';
import { getOutdatedPhotosCount } from '@/photo/db/query';
const BASIC_PHOTO_INSTALLATION_COUNT = 32;
const TAG_COUNT_THRESHOLD = 12;
export default async function AdminAppInsights() {
const [
@ -63,6 +65,10 @@ export default async function AdminAppInsights() {
noConfiguredDomain,
outdatedPhotos,
photoMatting: photosCountPortrait > 0 && !MATTE_PHOTOS,
camerasFirst: (
tags.length > TAG_COUNT_THRESHOLD &&
!SHOW_SIDEBAR_CAMERAS_FIRST
),
gridFirst: (
photosCount >= BASIC_PHOTO_INSTALLATION_COUNT &&
!GRID_HOMEPAGE_ENABLED

View File

@ -7,6 +7,7 @@ import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { FaCamera } from 'react-icons/fa';
import { FaTag } from 'react-icons/fa';
import { FaCircleInfo, FaRegCalendar } from 'react-icons/fa6';
import { HiMiniArrowsUpDown } from 'react-icons/hi2';
import { HiOutlinePhotograph } from 'react-icons/hi';
import { MdAspectRatio } from 'react-icons/md';
import { PiWarningBold } from 'react-icons/pi';
@ -107,6 +108,7 @@ export default function AdminAppInsightsClient({
noConfiguredDomain,
outdatedPhotos,
photoMatting,
camerasFirst,
gridFirst,
noStaticOptimization,
} = insights;
@ -271,7 +273,10 @@ export default function AdminAppInsightsClient({
Not explicitly setting a domain may cause certain features
to behave unexpectedly. Domains are stored in
{' '}
<EnvVar variable="NEXT_PUBLIC_SITE_DOMAIN" />.
<EnvVar
variable="NEXT_PUBLIC_SITE_DOMAIN"
trailingContent="."
/>
</>}
/>}
{(noStaticOptimization || debug) && <ScoreCardRow
@ -313,7 +318,10 @@ export default function AdminAppInsightsClient({
expandContent={<>
Enable automatic AI text generation
{' '}
by setting <EnvVar variable="OPENAI_SECRET_KEY" />.
by setting <EnvVar
variable="OPENAI_SECRET_KEY"
trailingContent="."
/>
{' '}
Further instruction and cost considerations in
{' '}
@ -331,7 +339,28 @@ export default function AdminAppInsightsClient({
{' '}
portrait and landscape photos appear more consistent
{' '}
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" value="1" />.
<EnvVar
variable="NEXT_PUBLIC_MATTE_PHOTOS"
value="1"
trailingContent="."
/>
</>}
/>}
{(camerasFirst || debug) && <ScoreCardRow
icon={<HiMiniArrowsUpDown
size={17}
className="translate-x-[-1px]"
/>}
content="Move cameras above tags in sidebar"
expandContent={<>
Now that you have more than a few tags, consider
showing cameras first in the sidebar by setting
{' '}
<EnvVar
variable="SHOW_SIDEBAR_CAMERAS_FIRST"
value="1"
trailingContent="."
/>
</>}
/>}
{(gridFirst || debug) && <ScoreCardRow

View File

@ -19,7 +19,7 @@ export default function InsightsIndicatorDot({
bottom?: number
left?: number
}) {
const { insightIndicatorStatus } = useAppState();
const { insightsIndicatorStatus } = useAppState();
const getSize = () => {
switch (size) {
@ -39,7 +39,7 @@ export default function InsightsIndicatorDot({
bottom !== undefined ||
left !== undefined
) && 'absolute',
(colorOverride ?? insightIndicatorStatus) === 'blue'
(colorOverride ?? insightsIndicatorStatus) === 'blue'
? 'text-blue-500'
: 'text-amber-500',
className,

View File

@ -10,41 +10,39 @@ import {
import { PhotoDateRange } from '@/photo';
import { getGitHubMeta } from '@/platforms/github';
type AdminAppInsightCode =
'noFork' |
'forkBehind';
const AdminAppInsightCode = [
'noFork',
'forkBehind',
] as const;
type AdminAppInsightCode = typeof AdminAppInsightCode[number];
type AdminAppInsightRecommendation =
'noAi' |
'noAiRateLimiting' |
'noConfiguredDomain' |
'photoMatting' |
'gridFirst' |
'noStaticOptimization';
const _INSIGHTS_TEMPLATE = [
'noAi',
'noAiRateLimiting',
'noConfiguredDomain',
'photoMatting',
'camerasFirst',
'gridFirst',
'noStaticOptimization',
] as const;
type AdminAppInsightRecommendation = typeof _INSIGHTS_TEMPLATE[number];
type AdminAppInsightLibrary =
'outdatedPhotos';
const _INSIGHTS_LIBRARY = [
'outdatedPhotos',
] as const;
type AdminAppInsightLibrary = typeof _INSIGHTS_LIBRARY[number];
export type AdminAppInsight =
AdminAppInsightCode |
AdminAppInsightRecommendation |
AdminAppInsightLibrary;
const RECOMMENDATIONS: AdminAppInsightRecommendation[] = [
'noAi',
'noAiRateLimiting',
'noConfiguredDomain',
'photoMatting',
'gridFirst',
'noStaticOptimization',
];
export type AdminAppInsights = Record<AdminAppInsight, boolean>
export type InsightIndicatorStatus = 'blue' | 'yellow' | undefined;
export type InsightsIndicatorStatus = 'blue' | 'yellow' | undefined;
export const hasTemplateRecommendations = (insights: AdminAppInsights) =>
RECOMMENDATIONS.some(insight => insights[insight]);
_INSIGHTS_TEMPLATE.some(insight => insights[insight]);
export interface PhotoStats {
photosCount: number
@ -87,3 +85,20 @@ export const getSignificantInsights = ({
outdatedPhotos: Boolean(photosCountOutdated),
};
};
export const indicatorStatusForSignificantInsights = (
insights: Awaited<ReturnType<typeof getSignificantInsights>>,
) => {
const {
forkBehind,
noAiRateLimiting,
noConfiguredDomain,
outdatedPhotos,
} = insights;
if (noAiRateLimiting || noConfiguredDomain) {
return 'yellow';
} else if (forkBehind || outdatedPhotos) {
return 'blue';
}
};

View File

@ -1,8 +1,11 @@
import { getOutdatedPhotosCount } from '@/photo/db/query';
import { getSignificantInsights } from '.';
import {
getSignificantInsights,
indicatorStatusForSignificantInsights,
} from '.';
import { getGitHubMetaForCurrentApp } from '.';
export const getShouldShowInsightsIndicator = async () => {
export const getInsightsIndicatorStatus = async () => {
const [
codeMeta,
photosCountOutdated,
@ -11,19 +14,10 @@ export const getShouldShowInsightsIndicator = async () => {
getOutdatedPhotosCount(),
]);
const {
forkBehind,
noAiRateLimiting,
noConfiguredDomain,
outdatedPhotos,
} = getSignificantInsights({
const significantInsights = getSignificantInsights({
codeMeta,
photosCountOutdated,
});
if (noAiRateLimiting || noConfiguredDomain) {
return 'yellow';
} else if (forkBehind || outdatedPhotos) {
return 'blue';
}
return indicatorStatusForSignificantInsights(significantInsights);
};

View File

@ -221,6 +221,8 @@ export const SHOW_RECIPES =
process.env.NEXT_PUBLIC_HIDE_RECIPES !== '1';
export const SHOW_REPO_LINK =
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
export const SHOW_SIDEBAR_CAMERAS_FIRST =
process.env.NEXT_PUBLIC_CAMERAS_FIRST === '1';
// GRID
@ -317,6 +319,7 @@ export const APP_CONFIGURATION = {
showFilmSimulations: SHOW_FILM_SIMULATIONS,
showRecipes: SHOW_RECIPES,
showRepoLink: SHOW_REPO_LINK,
showSidebarCamerasFirst: SHOW_SIDEBAR_CAMERAS_FIRST,
// Grid
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
gridAspectRatio: GRID_ASPECT_RATIO,

View File

@ -34,13 +34,18 @@ export default function EnvVar({
{variable}{value && ` = ${value}`}
</span>
{includeCopyButton &&
<CopyButton
className="translate-y-[0.5px]"
label={variable}
text={variable}
subtle
/>}
{trailingContent}
<span className="translate-y-[1px]">
<CopyButton
className=""
label={variable}
text={variable}
subtle
/>
</span>}
{trailingContent &&
<span className="-ml-0.5">
{trailingContent}
</span>}
</span>
</div>
);

View File

@ -108,7 +108,7 @@ export default function CommandKClient({
tagsCount,
selectedPhotoIds,
setSelectedPhotoIds,
insightIndicatorStatus,
insightsIndicatorStatus,
isGridHighDensity,
areZoomControlsShown,
arePhotosMatted,
@ -365,7 +365,7 @@ export default function CommandKClient({
adminSection.items.push({
label: <span className="flex items-center gap-3">
App Insights
{insightIndicatorStatus &&
{insightsIndicatorStatus &&
<InsightsIndicatorDot />}
</span>,
keywords: ['app insights'],

View File

@ -15,7 +15,7 @@ import FavsTag from '../tag/FavsTag';
import { useAppState } from '@/state/AppState';
import { useMemo } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import { SITE_ABOUT } from '@/app/config';
import { SHOW_SIDEBAR_CAMERAS_FIRST, SITE_ABOUT } from '@/app/config';
import {
htmlHasBrParagraphBreaks,
safelyParseFormattedHtml,
@ -43,6 +43,107 @@ export default function PhotoGridSidebar({
addHiddenToTags(tags, photosCountHidden)
, [tags, photosCountHidden]);
const tagsContent = tags.length > 0
? <HeaderList
title='Tags'
icon={<FaTag
size={12}
className="text-icon translate-y-[1px]"
/>}
items={tagsIncludingHidden.map(({ tag, count }) => {
switch (tag) {
case TAG_FAVS:
return <FavsTag
key={TAG_FAVS}
countOnHover={count}
type="icon-last"
prefetch={false}
contrast="low"
badged
/>;
case TAG_HIDDEN:
return <HiddenTag
key={TAG_HIDDEN}
countOnHover={count}
type="icon-last"
prefetch={false}
contrast="low"
badged
/>;
default:
return <PhotoTag
key={tag}
tag={tag}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
badged
/>;
}
})}
/>
: null;
const camerasContent = cameras.length > 0
? <HeaderList
title="Cameras"
icon={<IoMdCamera
size={13}
className="text-icon translate-y-[-0.25px]"
/>}
items={cameras
.sort(sortCamerasWithCount)
.map(({ cameraKey, camera, count }) =>
<PhotoCamera
key={cameraKey}
camera={camera}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
hideAppleIcon
badged
/>)}
/>
: null;
const filmsContent = simulations.length > 0
? <HeaderList
title="Films"
icon={<PhotoFilmSimulationIcon
className="translate-y-[0.5px]"
/>}
items={simulations
.sort(sortFilmSimulationsWithCount)
.map(({ simulation, count }) =>
<div
key={simulation}
className="translate-x-[-2px]"
>
<PhotoFilmSimulation
simulation={simulation}
countOnHover={count}
type="text-only"
prefetch={false}
/>
</div>)}
/>
: null;
const photoStatsContent = photosCount > 0
? start
? <HeaderList
title={photoQuantityText(photosCount, false)}
items={start === end
? [start]
: [`${end} `, start]}
/>
: <HeaderList
items={[photoQuantityText(photosCount, false)]}
/>
: null;
return (
<div className="space-y-4">
{SITE_ABOUT && <HeaderList
@ -57,95 +158,11 @@ export default function PhotoGridSidebar({
}}
/>]}
/>}
{tags.length > 0 && <HeaderList
title='Tags'
icon={<FaTag
size={12}
className="text-icon translate-y-[1px]"
/>}
items={tagsIncludingHidden.map(({ tag, count }) => {
switch (tag) {
case TAG_FAVS:
return <FavsTag
key={TAG_FAVS}
countOnHover={count}
type="icon-last"
prefetch={false}
contrast="low"
badged
/>;
case TAG_HIDDEN:
return <HiddenTag
key={TAG_HIDDEN}
countOnHover={count}
type="icon-last"
prefetch={false}
contrast="low"
badged
/>;
default:
return <PhotoTag
key={tag}
tag={tag}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
badged
/>;
}
})}
/>}
{cameras.length > 0 && <HeaderList
title="Cameras"
icon={<IoMdCamera
size={13}
className="text-icon translate-y-[-0.25px]"
/>}
items={cameras
.sort(sortCamerasWithCount)
.map(({ cameraKey, camera, count }) =>
<PhotoCamera
key={cameraKey}
camera={camera}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
hideAppleIcon
badged
/>)}
/>}
{simulations.length > 0 && <HeaderList
title="Films"
icon={<PhotoFilmSimulationIcon
className="translate-y-[0.5px]"
/>}
items={simulations
.sort(sortFilmSimulationsWithCount)
.map(({ simulation, count }) =>
<div
key={simulation}
className="translate-x-[-2px]"
>
<PhotoFilmSimulation
simulation={simulation}
countOnHover={count}
type="text-only"
prefetch={false}
/>
</div>)}
/>}
{photosCount > 0 && start
? <HeaderList
title={photoQuantityText(photosCount, false)}
items={start === end
? [start]
: [`${end} `, start]}
/>
: <HeaderList
items={[photoQuantityText(photosCount, false)]}
/>}
{SHOW_SIDEBAR_CAMERAS_FIRST
? <>{camerasContent}{tagsContent}</>
: <>{tagsContent}{camerasContent}</>}
{filmsContent}
{photoStatsContent}
</div>
);
}

View File

@ -7,7 +7,7 @@ import {
} from 'react';
import { AnimationConfig } from '@/components/AnimateItems';
import { ShareModalProps } from '@/share';
import { InsightIndicatorStatus } from '@/admin/insights';
import { InsightsIndicatorStatus } from '@/admin/insights';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
export interface AppStateContext {
@ -52,8 +52,8 @@ export interface AppStateContext {
setSelectedPhotoIds?: Dispatch<SetStateAction<string[] | undefined>>
isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
insightIndicatorStatus?: InsightIndicatorStatus
setInsightIndicatorStatus?: Dispatch<SetStateAction<InsightIndicatorStatus>>
insightsIndicatorStatus?: InsightsIndicatorStatus
setInsightsIndicatorStatus?: Dispatch<SetStateAction<InsightsIndicatorStatus>>
// DEBUG
isGridHighDensity?: boolean
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>

View File

@ -14,7 +14,7 @@ import {
} from '@/app/config';
import { ShareModalProps } from '@/share';
import { storeTimezoneCookie } from '@/utility/timezone';
import { InsightIndicatorStatus } from '@/admin/insights';
import { InsightsIndicatorStatus } from '@/admin/insights';
import { getAdminDataAction } from '@/admin/actions';
import {
storeAuthEmailCookie,
@ -72,8 +72,8 @@ export default function AppStateProvider({
useState<string[] | undefined>();
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false);
const [insightIndicatorStatus, setInsightIndicatorStatus] =
useState<InsightIndicatorStatus>();
const [insightsIndicatorStatus, setInsightsIndicatorStatus] =
useState<InsightsIndicatorStatus>();
// DEBUG
const [isGridHighDensity, setIsGridHighDensity] =
useState(HIGH_DENSITY_GRID);
@ -138,7 +138,7 @@ export default function AppStateProvider({
setPhotosCountHidden(adminData.countHiddenPhotos);
setUploadsCount(adminData.countUploads);
setTagsCount(adminData.countTags);
setInsightIndicatorStatus(adminData.shouldShowInsightsIndicator);
setInsightsIndicatorStatus(adminData.insightsIndicatorStatus);
}
} else {
setPhotosCountHidden(0);
@ -205,8 +205,8 @@ export default function AppStateProvider({
setSelectedPhotoIds,
isPerformingSelectEdit,
setIsPerformingSelectEdit,
insightIndicatorStatus,
setInsightIndicatorStatus,
insightsIndicatorStatus,
setInsightsIndicatorStatus,
// DEBUG
isGridHighDensity,
setIsGridHighDensity,